diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/ark/lightning.js | 224 | ||||
| -rw-r--r-- | src/ark/manager.js | 431 | ||||
| -rw-r--r-- | src/ark/proto.js | 40 | ||||
| -rw-r--r-- | src/ark/refresh.js | 4 | ||||
| -rw-r--r-- | src/ark/send.js | 157 | ||||
| -rw-r--r-- | src/ark/validate.js | 6 | ||||
| -rw-r--r-- | src/features/ark.js | 238 | ||||
| -rw-r--r-- | src/features/swaps.js | 9 | ||||
| -rw-r--r-- | src/i18n.js | 12 |
9 files changed, 1073 insertions, 48 deletions
diff --git a/src/ark/lightning.js b/src/ark/lightning.js new file mode 100644 index 0000000..c9d3ce1 --- /dev/null +++ b/src/ark/lightning.js @@ -0,0 +1,224 @@ +// Native-JS Ark <-> Lightning via the ASP (captaind's built-in submarine-swap +// flows): bolt11 decoding, the lightning fee schedules, and the gRPC-web +// wrappers for the pay (HTLC-out) and receive (HTLC-in) protocols. +// +// Mirrors bark's bark/src/actions/lightning/{pay,receive}.rs and +// server-rpc/protos/bark_server.proto. The ASP is the HTLC counterparty: +// paying converts pubkey vtxos into ServerHtlcSend vtxos the server can only +// claim by revealing the invoice preimage; receiving grants ServerHtlcRecv +// vtxos we claim by revealing our preimage. Worst case either side falls back +// to a unilateral on-chain exit — same trust class as a Boltz submarine swap. + +import { bech32, hex } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; +import { schnorr } from '@noble/curves/secp256k1'; + +import { concatBytes, grpcCall, pbWriter, pbFields } from './proto.js'; +import { parsePackageCosignResponse } from './send.js'; + +const te = new TextEncoder(); +const td = new TextDecoder(); + +// --------------------------------------------------------------------------- +// bolt11 (decode only: payment hash, amount, expiry, network) +// --------------------------------------------------------------------------- + +function convertBits(data, from, to) { + let acc = 0, bits = 0; const out = []; const maxv = (1 << to) - 1; + for (const v of data) { acc = (acc << from) | v; bits += from; while (bits >= to) { bits -= to; out.push((acc >> bits) & maxv); } } + return Uint8Array.from(out); +} + +export function decodeBolt11(invoice) { + const inv = String(invoice || '').trim().toLowerCase().replace(/^lightning:/, ''); + const dec = bech32.decode(inv, 4000); + const m = dec.prefix.match(/^ln(bcrt|bc|tbs|tb)(\d+)?([munp])?$/); + if (!m) throw new Error('not a bolt11 invoice'); + const network = { bc: 'mainnet', tb: 'testnet', tbs: 'signet', bcrt: 'regtest' }[m[1]]; + let amountMsat = null; + if (m[2]) { + const n = BigInt(m[2]); + amountMsat = m[3] === 'p' ? n / 10n : n * ({ m: 100000000n, u: 100000n, n: 100n }[m[3]] ?? 100000000000n); + } + const words = dec.words; + let ts = 0; + for (let i = 0; i < 7; i++) ts = ts * 32 + words[i]; + let payHash = null, expiry = 3600; + const end = words.length - 104; // last 104 words = signature + for (let i = 7; i + 3 <= end;) { + const type = words[i]; + const len = (words[i + 1] << 5) | words[i + 2]; + const data = words.slice(i + 3, i + 3 + len); + if (type === 1 && len === 52) payHash = convertBits(data, 5, 8).slice(0, 32); + if (type === 6) { expiry = 0; for (const w of data) expiry = expiry * 32 + w; } + i += 3 + len; + } + if (!payHash) throw new Error('invoice missing payment hash'); + return { + network, + paymentHash: hex.encode(payHash), + amountMsat, + amountSat: amountMsat != null ? Number(amountMsat / 1000n) : null, + expiresAt: (ts + expiry) * 1000, + }; +} + +// Decoded invoice or null — the "is this a bolt11?" test. +export function maybeBolt11(text) { + try { return decodeBolt11(text); } catch { return null; } +} + +// --------------------------------------------------------------------------- +// fee schedules (ArkInfo.fees.lightning_send / lightning_receive) +// --------------------------------------------------------------------------- + +// Send fee: base + ppm on the paid amount, ppm entry picked per input vtxo by +// its blocks-to-expiry (ascending table, last entry <= blocks wins), floored +// at min. Rounded up — never undershoots what the server will charge. +export function lnSendFee(amountSat, fees, inputs, tip) { + let remaining = amountSat; + let ppmMicro = 0n; + for (const v of inputs) { + const charge = Math.min(v.amountSat, remaining); + remaining -= charge; + const blocks = v.expiryHeight - tip; + const entry = (fees.ppmExpiryTable || []).filter((e) => blocks >= e.thresholdBlocks).pop(); + if (entry) ppmMicro += BigInt(charge) * BigInt(entry.ppm); + } + const ppmFee = Number((ppmMicro + 999_999n) / 1_000_000n); + return Math.max(fees.minFeeSat || 0, (fees.baseFeeSat || 0) + ppmFee); +} + +export const lnReceiveFee = (amountSat, fees) => + (fees.baseFeeSat || 0) + Math.ceil((amountSat * (fees.ppm || 0)) / 1_000_000); + +// --------------------------------------------------------------------------- +// pay (HTLC-out) +// --------------------------------------------------------------------------- + +// Cosign the conversion of input vtxos into ServerHtlcSend vtxos. +// parts: serialized ArkoorCosignRequest messages (send.js cosignPartBytes). +export async function requestLightningPayHtlcCosign(ark, parts) { + const req = pbWriter(); + for (const p of parts) req.bytesField(2, p); // LightningPayHtlcCosignRequest.parts = 2 + const resp = await grpcCall(ark, 'bark_server.ArkService/RequestLightningPayHtlcCosign', req.finish()); + return parsePackageCosignResponse(resp); +} + +export async function initiateLightningPayment(ark, { invoice, htlcVtxoIdRaws, amountSat, mailboxPubkey }) { + const w = pbWriter(); + w.stringField(1, invoice); + for (const id of htlcVtxoIdRaws) w.bytesField(2, id); + w.varintField(3, amountSat); + if (mailboxPubkey) w.bytesField(4, mailboxPubkey); + await grpcCall(ark, 'bark_server.ArkService/InitiateLightningPayment', w.finish()); +} + +// -> { status: 'pending'|'success'|'failed', preimage? (hex) } +export async function checkLightningPayment(ark, paymentHash, wait = false) { + const w = pbWriter(); + w.bytesField(1, paymentHash); + if (wait) w.varintField(2, 1); + const data = await grpcCall(ark, 'bark_server.ArkService/CheckLightningPayment', w.finish()); + for (const { field, value } of pbFields(data)) { + if (field === 2) { + let preimage = null; + for (const f of pbFields(value)) if (f.field === 1) preimage = hex.encode(f.value); + return { status: 'success', preimage }; + } + if (field === 3) return { status: 'failed' }; + } + return { status: 'pending' }; +} + +// Cosign the revocation of failed-payment HTLC vtxos back to a pubkey policy. +export async function requestLightningPayHtlcRevocation(ark, parts) { + const req = pbWriter(); + for (const p of parts) req.bytesField(1, p); // ArkoorPackageCosignRequest.parts = 1 + const resp = await grpcCall(ark, 'bark_server.ArkService/RequestLightningPayHtlcRevocation', req.finish()); + return parsePackageCosignResponse(resp); +} + +// --------------------------------------------------------------------------- +// receive (HTLC-in) +// --------------------------------------------------------------------------- + +export const LN_RECV_STATUS = ['created', 'accepted', 'htlcsReady', 'settled', 'canceled']; + +export async function startLightningReceive(ark, { paymentHash, amountSat, minCltvDelta, mailboxPubkey, description }) { + const w = pbWriter(); + w.bytesField(1, paymentHash); + w.varintField(2, amountSat); + w.varintField(3, minCltvDelta); + if (mailboxPubkey) w.bytesField(4, mailboxPubkey); + if (description) w.stringField(5, description); + const data = await grpcCall(ark, 'bark_server.ArkService/StartLightningReceive', w.finish()); + for (const { field, value } of pbFields(data)) if (field === 1) return td.decode(value); + throw new Error('server returned no invoice'); +} + +function decodeReceiveStatus(value) { + const out = { invoice: null, amountSat: 0, status: 'created', htlcVtxos: [] }; + for (const f of pbFields(value)) { + if (f.field === 1) out.invoice = td.decode(f.value); + if (f.field === 2) out.amountSat = Number(f.value); + if (f.field === 3) out.status = LN_RECV_STATUS[Number(f.value)] || 'created'; + if (f.field === 4) out.htlcVtxos.push(f.value); + } + return out; +} + +export async function checkLightningReceive(ark, paymentHash) { + const w = pbWriter(); + w.bytesField(1, paymentHash); + const data = await grpcCall(ark, 'bark_server.ArkService/CheckLightningReceive', w.finish()); + return decodeReceiveStatus(data); +} + +// "Lightning receive VTXO challenge" — 32-byte prefix (anti-DoS ownership proof) +const LN_RECV_ATTESTATION_PREFIX = te.encode('Lightning receive VTXO challenge'); + +export const lightningReceiveAttestation = (paymentHash, vtxoIdRaw, vtxoPrivkey) => + schnorr.sign(sha256(concatBytes(LN_RECV_ATTESTATION_PREFIX, paymentHash, vtxoIdRaw)), vtxoPrivkey); + +// antiDos: { vtxoIdRaw, attestation } | { token } | null +export async function prepareLightningReceiveClaim(ark, { paymentHash, userPubkey, htlcRecvExpiry, antiDos }) { + const w = pbWriter(); + w.bytesField(1, paymentHash); + w.bytesField(2, userPubkey); + w.varintField(3, htlcRecvExpiry); + if (antiDos && antiDos.vtxoIdRaw) { + const iv = pbWriter(); + iv.bytesField(1, antiDos.vtxoIdRaw); + iv.bytesField(2, antiDos.attestation); + w.bytesField(4, iv.finish()); + } else if (antiDos && antiDos.token) { + w.stringField(5, antiDos.token); + } + const data = await grpcCall(ark, 'bark_server.ArkService/PrepareLightningReceiveClaim', w.finish()); + const out = { receive: null, htlcVtxos: [] }; + for (const { field, value } of pbFields(data)) { + if (field === 1) out.receive = decodeReceiveStatus(value); + if (field === 2) out.htlcVtxos.push(value); + } + return out; +} + +// Reveal the preimage in exchange for a cosigned claim of the HTLC vtxos. +// parts: serialized ArkoorCosignRequest messages. Idempotent server-side. +export async function claimLightningReceive(ark, { paymentHash, preimage, parts }) { + const pkg = pbWriter(); + for (const p of parts) pkg.bytesField(1, p); + const w = pbWriter(); + w.bytesField(1, paymentHash); + w.bytesField(2, preimage); + w.bytesField(4, pkg.finish()); + const resp = await grpcCall(ark, 'bark_server.ArkService/ClaimLightningReceive', w.finish()); + return parsePackageCosignResponse(resp); +} + +export async function cancelLightningReceive(ark, paymentHash) { + const w = pbWriter(); + w.bytesField(1, paymentHash); + await grpcCall(ark, 'bark_server.ArkService/CancelLightningReceive', w.finish()); +} diff --git a/src/ark/manager.js b/src/ark/manager.js index 86d496a..e6c4174 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -26,8 +26,16 @@ import { import { buildArkoorSend, cosignWithServer, buildSignedVtxoBytes, registerVtxoTransactions, postArkoorMessage, txid, + genUserNonces, cosignPartBytes, combineCosign, } from './send.js'; import { + decodeBolt11, lnSendFee, lnReceiveFee, + requestLightningPayHtlcCosign, initiateLightningPayment, checkLightningPayment, + requestLightningPayHtlcRevocation, startLightningReceive, checkLightningReceive, + prepareLightningReceiveClaim, claimLightningReceive, cancelLightningReceive, + lightningReceiveAttestation, +} from './lightning.js'; +import { boardFee, p2trAddress, buildBoard, requestBoardCosign, combineBoardSignature, encodeBoardVtxo, registerBoardVtxo, } from './board.js'; @@ -63,6 +71,7 @@ export class ArkManager { this.onUpdate = onUpdate || (() => {}); this.state = null; this.info = null; + this._lnDriving = new Set(); // re-entrancy guard: sync poll vs UI fast-poll vs mailbox push } // ---- keys ---- @@ -160,7 +169,7 @@ export class ArkManager { const ack = this.state.receiveAckTs || 0; const cutoff = Date.now() - 2 * 3600 * 1000; return this.state.movements.filter((m) => - m.type === 'receive' && m.status === 'complete' && m.ts > ack && m.ts > cutoff); + ['receive', 'ln-receive'].includes(m.type) && m.status === 'complete' && m.ts > ack && m.ts > cutoff); } ackReceives() { this.state.receiveAckTs = Date.now(); @@ -187,6 +196,26 @@ export class ArkManager { async _processMailboxMessage(m) { let changed = false; if (m.checkpoint > this.state.mailboxCheckpoint) { this.state.mailboxCheckpoint = m.checkpoint; changed = true; } + if (m.kind === 'lnSendFinished') { + const a = this.state.actions.find((x) => + x.type === 'ln-pay' && x.paymentHash === m.paymentHash && !['done', 'failed'].includes(x.step)); + if (a && m.preimage && a.step === 'initiated' + && hex.encode(sha256(hex.decode(m.preimage))) === a.paymentHash) { + this._settleLnPay(a, m.preimage); + changed = true; + } else if (a && !m.preimage && a.step === 'initiated') { + a.step = 'revoking'; + changed = true; + this._driveLnPay(a).catch(() => {}); // revocation retried on sync if this fails + } + return changed; + } + if (m.kind === 'lnIncoming') { + const a = this.state.actions.find((x) => + x.type === 'ln-recv' && x.paymentHash === m.paymentHash && !['done', 'failed'].includes(x.step)); + if (a) this._driveLnRecv(a).catch(() => {}); // claim promptly; sync poll is the fallback + return changed; + } if (m.kind !== 'arkoor') return changed; const ourKeys = [hex.encode(this._key(0).pubkey)]; for (const v of m.vtxos) { @@ -274,9 +303,13 @@ export class ArkManager { if (action.type === 'board') await this._driveBoard(action); if (action.type === 'refresh') await this._driveRefresh(action); if (action.type === 'offboard') await this._driveOffboard(action); + if (action.type === 'ln-pay') await this._driveLnPay(action); + if (action.type === 'ln-recv') await this._driveLnRecv(action); + if (action.lastError) { delete action.lastError; this._save(); } } catch (e) { // transient errors leave the action where it is; a later sync retries if (!(e instanceof GrpcError)) throw e; + if (action.type.startsWith('ln-')) { action.lastError = e.message; this._save(); } } } } @@ -502,6 +535,402 @@ export class ArkManager { } } + // ---- lightning (the ASP is the swap counterparty; see ark/lightning.js) ---- + + // Deterministic receive preimage: no secret ever hits storage — only the + // derivation index does, so a restored seed can always re-derive it. + _lnPreimage(idx) { + return sha256(this.account.deriveChild(5).deriveChild(idx).privateKey); + } + + _expiryMargin() { return this.network === 'regtest' ? 12 : 144; } + + lnAction(id) { + return this.state.actions.find((a) => a.id === id && a.type.startsWith('ln-')); + } + + // Drive one lightning action forward (the UI's fast poll while its screen + // is open); transient gRPC errors are recorded, not thrown. + async driveLn(id) { + const a = this.lnAction(id); + if (!a || ['done', 'failed'].includes(a.step)) return a; + try { + if (a.type === 'ln-pay') await this._driveLnPay(a); + else await this._driveLnRecv(a); + if (a.lastError) { delete a.lastError; this._save(); } + } catch (e) { + if (!(e instanceof GrpcError)) throw e; + a.lastError = e.message; + this._save(); + } + return a; + } + + // Pay a bolt11 invoice with ark funds. Returns the action id; drive to a + // terminal step ('done' | 'failed') via driveLn()/sync. + async payLnInvoice(invoice, { amountSat: userAmountSat } = {}) { + const dec = decodeBolt11(invoice); + const expectNet = { bitcoin: 'mainnet', regtest: 'regtest', signet: 'signet', testnet: 'testnet' }[this.info.network]; + if (expectNet && dec.network !== expectNet) throw new Error(`invoice is for ${dec.network}, wallet is on ${expectNet}`); + const amountSat = dec.amountSat ?? userAmountSat; + if (!amountSat || amountSat <= 0) throw new Error('invoice has no amount'); + if (this.state.actions.some((a) => + a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step !== 'failed')) { + throw new Error('this invoice was already paid or is being paid'); + } + const tip = await this.chain.tipHeight(); + // smallest single vtxo that covers amount + its expiry-dependent fee + const candidates = this.state.vtxos + .filter((v) => v.state === 'spendable') + .sort((a, b) => a.amountSat - b.amountSat); + let input = null, feeSat = 0; + for (const v of candidates) { + const fee = lnSendFee(amountSat, this.info.lnSendFees, [v], tip); + if (v.amountSat >= amountSat + fee) { input = v; feeSat = fee; break; } + } + if (!input) { + const total = this.balance().spendableSat; + throw new Error(total >= amountSat + ? 'no single vtxo covers this amount — consolidate with refresh() first' + : 'insufficient ark balance'); + } + const action = { + id: `lnpay-${Date.now()}`, type: 'ln-pay', step: 'created', + invoice, paymentHash: dec.paymentHash, amountSat, feeSat, + inputId: input.id, + htlcKeyIndex: this.state.nextKeyIndex++, // HTLC lock key, reused for change (bark does the same) + revKeyIndex: this.state.nextKeyIndex++, + htlcExpiry: tip + (this.info.htlcSendExpiryDelta || 258), + changeSat: input.amountSat - amountSat - feeSat, + }; + input.state = 'pending'; + this.state.actions.push(action); + this._save(); + await this._driveLnPay(action); + return action.id; + } + + async _driveLnPay(action) { + if (this._lnDriving.has(action.id)) return; + this._lnDriving.add(action.id); + try { await this._driveLnPayInner(action); } finally { this._lnDriving.delete(action.id); } + } + + async _driveLnPayInner(action) { + if (action.step === 'created') { + const inputRec = this._vtxo(action.inputId); + const input = this._decoded(inputRec); + const keys = this._keyForVtxo(inputRec); + const htlcKey = this._key(action.htlcKeyIndex); + const outputs = [{ + amountSat: action.amountSat + action.feeSat, + policy: { + type: 'serverHtlcSend', userPubkey: hex.encode(htlcKey.pubkey), + paymentHash: action.paymentHash, htlcExpiry: action.htlcExpiry, + }, + }]; + if (action.changeSat > 0) outputs.push({ amountSat: action.changeSat, userPubkey: htlcKey.pubkey }); + await registerVtxoTransactions(this.arkUrl, [input._raw.bytes]); + const build = buildArkoorSend({ input, outputs, serverPubkey: this.serverPub, vtxoKeys: keys }); + const nonces = genUserNonces(build, keys); + let resp; + try { + // idempotent on payment hash server-side: a re-drive after a crash + // gets fresh partials for fresh nonces + [resp] = await requestLightningPayHtlcCosign(this.arkUrl, + [cosignPartBytes({ build, input, vtxoKeys: keys, nonces })]); + } catch (e) { + if (e instanceof GrpcError && /already spent|not spendable/i.test(e.message)) { + inputRec.state = 'spent'; + action.step = 'failed'; + action.error = e.message; + this._movement({ type: 'ln-send', amountSat: action.amountSat, status: 'failed', detail: e.message }); + this._save(); + return; + } + throw e; + } + if (!resp) throw new Error('empty cosign response'); + const sigs = combineCosign({ build, nonces, serverResp: resp, vtxoKeys: keys, serverPubkey: this.serverPub }); + // the input is spent server-side from this moment: persist immediately + const htlcBytes = buildSignedVtxoBytes({ input, build, finalSigs: sigs, serverPubkey: this.serverPub, idx: 0 }); + action.htlcBytes = hex.encode(htlcBytes); + const htlcDecoded = decodeVtxo(htlcBytes); + action.htlcVtxoId = htlcDecoded.id; + this._addVtxo(htlcDecoded, htlcBytes, action.htlcKeyIndex, 'pending'); + if (action.changeSat > 0) { + const changeBytes = buildSignedVtxoBytes({ input, build, finalSigs: sigs, serverPubkey: this.serverPub, idx: 1 }); + action.changeBytes = hex.encode(changeBytes); + this._addVtxo(decodeVtxo(changeBytes), changeBytes, action.htlcKeyIndex, 'pending'); + } + inputRec.state = 'spent'; + action.step = 'cosigned'; + this._save(); + } + if (action.step === 'cosigned') { + const list = [hex.decode(action.htlcBytes)]; + if (action.changeBytes) list.push(hex.decode(action.changeBytes)); + await registerVtxoTransactions(this.arkUrl, list).catch(() => {}); // best-effort (bark warns too) + // the change is a plain cosigned vtxo, good regardless of what the + // payment does from here + if (action.changeBytes) { + const change = this._vtxo(decodeVtxo(hex.decode(action.changeBytes)).id); + if (change && change.state === 'pending') change.state = 'spendable'; + } + try { + await initiateLightningPayment(this.arkUrl, { + invoice: action.invoice, + htlcVtxoIdRaws: [decodeVtxo(hex.decode(action.htlcBytes)).point.raw], + amountSat: action.amountSat, + mailboxPubkey: this._mailboxKey().pubkey, + }); + action.step = 'initiated'; + this._save(); + } catch (e) { + // INVALID_ARGUMENT: the server itself deems the invoice unpayable — + // retrying can never succeed, revoke the HTLC instead + if (e instanceof GrpcError && e.grpcStatus === 3) { + action.error = e.message; + action.step = 'revoking'; + this._save(); + } else { + throw e; + } + } + } + if (action.step === 'initiated') { + const res = await checkLightningPayment(this.arkUrl, hex.decode(action.paymentHash), false); + if (res.status === 'success' && res.preimage + && hex.encode(sha256(hex.decode(res.preimage))) === action.paymentHash) { + this._settleLnPay(action, res.preimage); + return; + } + if (res.status === 'failed') { + action.step = 'revoking'; + this._save(); + } else { + // still pending: past the HTLC expiry the payment can't complete — revoke + const tip = await this.chain.tipHeight(); + if (tip <= action.htlcExpiry) return; // poll again next sync + action.step = 'revoking'; + this._save(); + } + } + if (action.step === 'revoking') await this._revokeLnPay(action); + } + + _settleLnPay(action, preimageHex) { + const htlc = this._vtxo(action.htlcVtxoId); + if (htlc) htlc.state = 'spent'; + action.preimage = preimageHex; + action.step = 'done'; + this._movement({ + type: 'ln-send', amountSat: action.amountSat, status: 'complete', + detail: action.feeSat ? `fee ${action.feeSat} sat` : undefined, + invoice: action.invoice, preimage: preimageHex, + }); + this._save(); + } + + // Payment failed: ask the server to cosign the HTLC vtxo back to a fresh + // pubkey vtxo of ours. A refusing/unreachable server leaves the action in + // 'revoking' (retried every sync); near expiry the remaining option is a + // unilateral exit of the HTLC vtxo. + async _revokeLnPay(action) { + const htlcRec = this._vtxo(action.htlcVtxoId); + const input = this._decoded(htlcRec); + const keys = this._key(action.htlcKeyIndex); + const revKey = this._key(action.revKeyIndex); + const build = buildArkoorSend({ + input, + outputs: [{ amountSat: input.amountSat, userPubkey: revKey.pubkey }], + serverPubkey: this.serverPub, vtxoKeys: keys, + }); + const nonces = genUserNonces(build, keys); + const [resp] = await requestLightningPayHtlcRevocation(this.arkUrl, + [cosignPartBytes({ build, input, vtxoKeys: keys, nonces })]); + if (!resp) throw new Error('empty revocation response'); + const sigs = combineCosign({ build, nonces, serverResp: resp, vtxoKeys: keys, serverPubkey: this.serverPub }); + const bytes = buildSignedVtxoBytes({ input, build, finalSigs: sigs, serverPubkey: this.serverPub, idx: 0 }); + await registerVtxoTransactions(this.arkUrl, [bytes]).catch(() => {}); + htlcRec.state = 'spent'; + this._addVtxo(decodeVtxo(bytes), bytes, action.revKeyIndex); + action.step = 'failed'; + action.error = action.error || 'payment failed'; + this._movement({ + type: 'ln-send', amountSat: action.amountSat, status: 'failed', + detail: 'payment failed — funds returned', invoice: action.invoice, + }); + this._save(); + } + + // Mint a bolt11 invoice payable to this wallet's ark balance. + async createLnInvoice(amountSat, description) { + if (!(amountSat > 0)) throw new Error('invalid amount'); + // CLTV budget: exit delta + server htlc delta + exit margin + claim delta + // + 2-block prepare leniency (bark's composition, defaults inlined) + const minCltvDelta = (this.info.vtxoExitDelta || 0) + (this.info.htlcExpiryDelta || 6) + 12 + 18 + 2; + if (this.info.maxUserInvoiceCltvDelta && minCltvDelta > this.info.maxUserInvoiceCltvDelta) { + throw new Error('server max invoice CLTV delta is too low'); + } + const idx = this.state.nextLnRecvIndex || 0; + this.state.nextLnRecvIndex = idx + 1; + const keyIndex = this.state.nextKeyIndex++; + const paymentHash = sha256(this._lnPreimage(idx)); + const invoice = await startLightningReceive(this.arkUrl, { + paymentHash, amountSat, minCltvDelta, + mailboxPubkey: this._mailboxKey().pubkey, description, + }); + const decInv = decodeBolt11(invoice); + if (decInv.paymentHash !== hex.encode(paymentHash)) { + throw new Error('server invoice payment hash mismatch'); + } + const action = { + id: `lnrecv-${Date.now()}`, type: 'ln-recv', step: 'awaiting', + paymentHash: hex.encode(paymentHash), preimageIndex: idx, keyIndex, + amountSat, minCltvDelta, invoice, expiresAt: decInv.expiresAt, + }; + this.state.actions.push(action); + this._save(); + return action; + } + + // Cancel an unpaid invoice. Only valid before HTLCs are granted; the server + // cancel is best-effort (an abandoned hold invoice expires server-side). + async cancelLnInvoice(id) { + const a = this.lnAction(id); + if (!a || a.type !== 'ln-recv') throw new Error('unknown invoice'); + if (a.step !== 'awaiting') throw new Error('receive already in progress'); + await cancelLightningReceive(this.arkUrl, hex.decode(a.paymentHash)).catch(() => {}); + a.step = 'failed'; + a.error = 'canceled'; + this._save(); + } + + async _driveLnRecv(action) { + if (this._lnDriving.has(action.id)) return; + this._lnDriving.add(action.id); + try { await this._driveLnRecvInner(action); } finally { this._lnDriving.delete(action.id); } + } + + async _driveLnRecvInner(action) { + if (action.step === 'awaiting') { + const st = await checkLightningReceive(this.arkUrl, hex.decode(action.paymentHash)); + if (st.status === 'canceled') { + action.step = 'failed'; + action.error = 'canceled server-side'; + this._save(); + return; + } + if (st.status === 'created') { + // unpaid past the invoice expiry (+grace): reap + if (action.expiresAt && Date.now() > action.expiresAt + 60_000) { + action.step = 'failed'; + action.error = 'invoice expired'; + this._save(); + } + return; + } + if (st.status === 'settled') { + // settled without us claiming — shouldn't happen; surface it + action.step = 'failed'; + action.error = 'settled server-side without local claim'; + this._save(); + return; + } + // accepted | htlcsReady: ask for our HTLC vtxos and validate them + const tip = await this.chain.tipHeight(); + const keys = this._key(action.keyIndex); + const htlcRecvExpiry = tip + action.minCltvDelta; + let antiDos = null; + if (this.info.lnReceiveAntiDosRequired) { + const proof = this.state.vtxos.filter((v) => v.state === 'spendable') + .map((v) => ({ v, d: this._decoded(v) })) + .find(({ d }) => d.expiryHeight > tip + 2); + if (proof) { + antiDos = { + vtxoIdRaw: proof.d.point.raw, + attestation: lightningReceiveAttestation( + hex.decode(action.paymentHash), proof.d.point.raw, this._keyForVtxo(proof.v).privkey), + }; + } + } + const res = await prepareLightningReceiveClaim(this.arkUrl, { + paymentHash: hex.decode(action.paymentHash), + userPubkey: keys.pubkey, htlcRecvExpiry, antiDos, + }); + if (!res.htlcVtxos.length) return; // not ready after all; retry on sync + let total = 0; + for (const b of res.htlcVtxos) { + const v = decodeVtxo(b); + if (v.policy.type !== 'serverHtlcRecv') throw new Error('unexpected HTLC vtxo policy from server'); + if (v.policy.paymentHash !== action.paymentHash) throw new Error('HTLC vtxo payment hash mismatch'); + if (v.policy.userPubkey !== hex.encode(keys.pubkey)) throw new Error('HTLC vtxo pubkey mismatch'); + if (v.policy.htlcExpiry < htlcRecvExpiry) throw new Error('HTLC vtxo expiry lower than requested'); + await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, allowPolicies: ['serverHtlcRecv'] }); + total += v.amountSat; + } + if (total + lnReceiveFee(action.amountSat, this.info.lnReceiveFees) < action.amountSat) { + throw new Error('server returned insufficient HTLC value'); + } + action.htlcBytes = res.htlcVtxos.map((b) => hex.encode(b)); + action.step = 'htlcsReady'; + this._save(); + } + if (action.step === 'htlcsReady') { + const keys = this._key(action.keyIndex); + const decoded = action.htlcBytes.map((b) => decodeVtxo(hex.decode(b))) + .sort((a, b) => (a.id < b.id ? -1 : 1)); + // preimage not revealed yet + HTLCs near expiry: abandon rather than + // commit (the inbound HTLC times out and the sender is refunded) + if (!action.preimageRevealed) { + const tip = await this.chain.tipHeight(); + if (tip > decoded[0].policy.htlcExpiry - this._expiryMargin()) { + action.step = 'failed'; + action.error = 'HTLCs near expiry — abandoned before preimage reveal'; + this._movement({ type: 'ln-receive', amountSat: action.amountSat, status: 'failed', detail: action.error }); + this._save(); + return; + } + } + const builds = decoded.map((input) => buildArkoorSend({ + input, + outputs: [{ amountSat: input.amountSat, userPubkey: keys.pubkey }], + serverPubkey: this.serverPub, vtxoKeys: keys, + })); + const noncesList = builds.map((b) => genUserNonces(b, keys)); + const parts = builds.map((b, i) => + cosignPartBytes({ build: b, input: decoded[i], vtxoKeys: keys, nonces: noncesList[i] })); + // past this point the preimage is out; the claim is idempotent + // server-side, so persist the fact and retry until it lands + action.preimageRevealed = true; + this._save(); + const resps = await claimLightningReceive(this.arkUrl, { + paymentHash: hex.decode(action.paymentHash), + preimage: this._lnPreimage(action.preimageIndex), + parts, + }); + if (resps.length !== builds.length) throw new Error('bad claim cosign response'); + const outBytes = builds.map((b, i) => { + const sigs = combineCosign({ + build: b, nonces: noncesList[i], serverResp: resps[i], + vtxoKeys: keys, serverPubkey: this.serverPub, + }); + return buildSignedVtxoBytes({ input: decoded[i], build: b, finalSigs: sigs, serverPubkey: this.serverPub, idx: 0 }); + }); + await registerVtxoTransactions(this.arkUrl, outBytes).catch(() => {}); + let total = 0; + for (const bts of outBytes) { + const v = decodeVtxo(bts); + this._addVtxo(v, bts, action.keyIndex); + total += v.amountSat; + } + action.step = 'done'; + this._movement({ type: 'ln-receive', amountSat: total, status: 'complete', invoice: action.invoice }); + this._save(); + } + } + // ---- board ---- // Returns the onchain funding address; hal's onchain wallet pays it (the // board output MUST be vout 0), then completeBoard(actionId, txid). diff --git a/src/ark/proto.js b/src/ark/proto.js index ae2342e..bb8918d 100644 --- a/src/ark/proto.js +++ b/src/ark/proto.js @@ -368,6 +368,8 @@ export async function getArkInfo(ark) { boardFees: {}, refreshFees: { baseFeeSat: 0, ppmExpiryTable: [] }, offboardFees: { baseFeeSat: 0, fixedAdditionalVb: 0, ppmExpiryTable: [] }, + lnReceiveFees: { baseFeeSat: 0, ppm: 0 }, + lnSendFees: { minFeeSat: 0, baseFeeSat: 0, ppmExpiryTable: [] }, }; for (const { field, value } of pbFields(data)) { if (field === 1) info.network = td.decode(value); @@ -375,8 +377,12 @@ export async function getArkInfo(ark) { if (field === 3) info.roundIntervalSecs = Number(value); if (field === 5) info.vtxoExitDelta = Number(value); if (field === 6) info.vtxoExpiryDelta = Number(value); + if (field === 7) info.htlcSendExpiryDelta = Number(value); + if (field === 8) info.htlcExpiryDelta = Number(value); if (field === 10) info.requiredBoardConfirmations = Number(value); + if (field === 11) info.maxUserInvoiceCltvDelta = Number(value); if (field === 13) info.minBoardAmountSat = Number(value); + if (field === 15) info.lnReceiveAntiDosRequired = Number(value) !== 0; if (field === 16) info.mailboxPubkey = hex.encode(value); if (field === 20) info.maxOffboardInputs = Number(value); if (field === 18) { // FeeSchedule @@ -421,6 +427,26 @@ export async function getArkInfo(ark) { } info.refreshFees = rf; } + if (f.field === 4) { // lightning receive + for (const g of pbFields(f.value)) { + if (g.field === 1) info.lnReceiveFees.baseFeeSat = Number(g.value); + if (g.field === 2) info.lnReceiveFees.ppm = Number(g.value); + } + } + if (f.field === 5) { // lightning send + for (const g of pbFields(f.value)) { + if (g.field === 1) info.lnSendFees.minFeeSat = Number(g.value); + if (g.field === 2) info.lnSendFees.baseFeeSat = Number(g.value); + if (g.field === 3) { + const e = { thresholdBlocks: 0, ppm: 0 }; + for (const h of pbFields(g.value)) { + if (h.field === 1) e.thresholdBlocks = Number(h.value); + if (h.field === 2) e.ppm = Number(h.value); + } + info.lnSendFees.ppmExpiryTable.push(e); + } + } + } } } } @@ -464,6 +490,20 @@ export function decodeMailboxMessage(value) { if (a.field === 1) msg.vtxos.push(decodeVtxo(a.value)); } } + if (f.field === 5) { // IncomingLightningPaymentMessage + msg.kind = 'lnIncoming'; + for (const a of pbFields(f.value)) { + if (a.field === 1) msg.paymentHash = hex.encode(a.value); + if (a.field === 2) msg.amountMsat = Number(a.value); + } + } + if (f.field === 7) { // LightningSendFinishedMessage + msg.kind = 'lnSendFinished'; + for (const a of pbFields(f.value)) { + if (a.field === 1) msg.paymentHash = hex.encode(a.value); + if (a.field === 2) msg.preimage = hex.encode(a.value); + } + } } return msg; } diff --git a/src/ark/refresh.js b/src/ark/refresh.js index 54a3fc8..adf4c85 100644 --- a/src/ark/refresh.js +++ b/src/ark/refresh.js @@ -12,7 +12,7 @@ import { schnorr, secp256k1 } from '@noble/curves/secp256k1'; import * as musig2 from '@scure/btc-signer/musig2'; import { grpcCall, pbWriter, pbFields, concatBytes, reader } from './proto.js'; -import { musigInternalKey, taprootSighash, txid, P2A_SCRIPT, pubkeyPolicyTaproot } from './send.js'; +import { musigInternalKey, taprootSighash, txid, P2A_SCRIPT, pubkeyPolicyTaproot, policyTaproot } from './send.js'; const te = new TextEncoder(); const u32le = (n) => Uint8Array.of(n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff); @@ -144,7 +144,7 @@ export function vtxoTransactions(vtxo, serverPub) { const nextOutput = i + 1 < vtxo.genesis.length ? transitionInputTxout(vtxo.genesis[i + 1].transition, nextAmount, serverPub, vtxo.expiryHeight) : { valueSat: vtxo.amountSat, - scriptPubKey: pubkeyPolicyTaproot(hex.decode(vtxo.policy.userPubkey), serverPub, vtxo.exitDelta).scriptPubKey }; + scriptPubKey: policyTaproot(vtxo.policy, serverPub, vtxo.exitDelta).scriptPubKey }; // decoded txouts carry hex-string scripts — normalize to bytes const others = item.otherOutputs.map((o) => ({ valueSat: o.valueSat, scriptPubKey: hex.decode(o.scriptPubKey) })); const outs = [ diff --git a/src/ark/send.js b/src/ark/send.js index a959b22..e4f294a 100644 --- a/src/ark/send.js +++ b/src/ark/send.js @@ -5,11 +5,15 @@ import { hex } from '@scure/base'; import { sha256 } from '@noble/hashes/sha256'; +import { ripemd160 } from '@noble/hashes/ripemd160'; import { schnorr, secp256k1 } from '@noble/curves/secp256k1'; import * as musig2 from '@scure/btc-signer/musig2'; import { concatBytes, reader, grpcCall, pbWriter, pbFields } from './proto.js'; +// hex string or byte array -> bytes (decoded vtxos carry hex-string fields) +const asBytes = (v) => (typeof v === 'string' ? hex.decode(v) : Uint8Array.from(v)); + const te = new TextEncoder(); // --------------------------------------------------------------------------- @@ -47,7 +51,7 @@ function pushInt(n) { return Uint8Array.of(bytes.length, ...bytes); } -const OP = { CSV: 0xb2, CLTV: 0xb1, DROP: 0x75, CHECKSIG: 0xac }; +const OP = { CSV: 0xb2, CLTV: 0xb1, DROP: 0x75, CHECKSIG: 0xac, HASH160: 0xa9, EQUALVERIFY: 0x88 }; export const P2A_SCRIPT = hex.decode('51024e73'); // delayed_sign: <csv> OP_CSV OP_DROP <xonly> OP_CHECKSIG @@ -58,6 +62,17 @@ const delayedSignScript = (delta, xonly) => const timelockSignScript = (height, xonly) => concatBytes(pushInt(height), Uint8Array.of(OP.CLTV, OP.DROP, 0x20), xonly, Uint8Array.of(OP.CHECKSIG)); +// hash_delay_sign: <csv> OP_CSV OP_DROP OP_HASH160 <ripemd160(hash)> OP_EQUALVERIFY <xonly> OP_CHECKSIG +// (the witness reveals the 32B preimage; HASH160(preimage) = ripemd160(sha256(preimage))) +const hashDelaySignScript = (paymentHash, delta, xonly) => concatBytes( + pushInt(delta), Uint8Array.of(OP.CSV, OP.DROP, OP.HASH160, 0x14), ripemd160(paymentHash), + Uint8Array.of(OP.EQUALVERIFY, 0x20), xonly, Uint8Array.of(OP.CHECKSIG)); + +// delay_timelock_sign: <height> OP_CLTV OP_DROP <csv> OP_CSV OP_DROP <xonly> OP_CHECKSIG +const delayTimelockSignScript = (height, delta, xonly) => concatBytes( + pushInt(height), Uint8Array.of(OP.CLTV, OP.DROP), pushInt(delta), + Uint8Array.of(OP.CSV, OP.DROP, 0x20), xonly, Uint8Array.of(OP.CHECKSIG)); + // --------------------------------------------------------------------------- // taproot + musig key aggregation // --------------------------------------------------------------------------- @@ -74,11 +89,20 @@ export function musigInternalKey(keys) { const tapLeafHash = (script) => taggedHash('TapLeaf', Uint8Array.of(0xc0), varint(script.length), script); -// One-leaf taproot around a musig2 internal key. +// BIP-341 branch hash: children sorted lexicographically +const tapBranch = (a, b) => { + let swap = false; + for (let i = 0; i < 32; i++) { if (a[i] !== b[i]) { swap = a[i] > b[i]; break; } } + return swap ? taggedHash('TapBranch', b, a) : taggedHash('TapBranch', a, b); +}; + +// Taproot around a musig2(user,server) internal key with 1 or 2 script leaves +// (two leaves sit at depth 1, mirroring bark's TaprootBuilder usage). // Returns everything both tx-building and signing need. -export function taprootOneLeaf(userPub, serverPub, leafScript) { +export function taprootFromLeaves(userPub, serverPub, leafScripts) { const { sortedKeys, internalXOnly } = musigInternalKey([userPub, serverPub]); - const merkleRoot = tapLeafHash(leafScript); + const leafHashes = leafScripts.map(tapLeafHash); + const merkleRoot = leafHashes.length === 1 ? leafHashes[0] : tapBranch(leafHashes[0], leafHashes[1]); const tapTweak = taggedHash('TapTweak', internalXOnly, merkleRoot); const P = secp256k1.ProjectivePoint; // lift internal x-only, add tweak*G @@ -91,6 +115,9 @@ export function taprootOneLeaf(userPub, serverPub, leafScript) { return { sortedKeys, internalXOnly, merkleRoot, tapTweak, outputXOnly, outputParity, scriptPubKey }; } +export const taprootOneLeaf = (userPub, serverPub, leafScript) => + taprootFromLeaves(userPub, serverPub, [leafScript]); + // PubkeyVtxoPolicy: keyspend musig(user,server); leaf = delayed_sign(exit_delta, user). // leafScript is returned so a unilateral exit can claim through it after the CSV. export const pubkeyPolicyTaproot = (userPub, serverPub, exitDelta) => { @@ -102,6 +129,30 @@ export const pubkeyPolicyTaproot = (userPub, serverPub, exitDelta) => { export const checkpointPolicyTaproot = (userPub, serverPub, expiryHeight) => taprootOneLeaf(userPub, serverPub, timelockSignScript(expiryHeight, xonly(serverPub))); +// Taproot for any user-facing VTXO policy (mirror of bark's VtxoPolicy::taproot). +// policy fields may be hex strings (decoded form) or byte arrays. +export function policyTaproot(policy, serverPub, exitDelta) { + const user = asBytes(policy.userPubkey); + if (policy.type === 'pubkey') return pubkeyPolicyTaproot(user, serverPub, exitDelta); + if (policy.type === 'serverHtlcSend') { + const hash = asBytes(policy.paymentHash); + // leaf 1: server spends with preimage after exit_delta + // leaf 2: user refund after htlc expiry + 2*exit_delta delay + const serverClaim = hashDelaySignScript(hash, exitDelta, xonly(serverPub)); + const userRefund = delayTimelockSignScript(policy.htlcExpiry, 2 * exitDelta, xonly(user)); + return { ...taprootFromLeaves(user, serverPub, [serverClaim, userRefund]), serverClaim, userRefund }; + } + if (policy.type === 'serverHtlcRecv') { + const hash = asBytes(policy.paymentHash); + // leaf 1: server reclaim after htlc expiry + exit_delta delay + // leaf 2: user spends with preimage after htlc_expiry_delta + exit_delta + const serverRefund = delayTimelockSignScript(policy.htlcExpiry, exitDelta, xonly(serverPub)); + const userClaim = hashDelaySignScript(hash, policy.htlcExpiryDelta + exitDelta, xonly(user)); + return { ...taprootFromLeaves(user, serverPub, [serverRefund, userClaim]), serverRefund, userClaim }; + } + throw new Error('unsupported vtxo policy type ' + policy.type); +} + // --------------------------------------------------------------------------- // transactions (version 3, zero locktime, sequence 0) // --------------------------------------------------------------------------- @@ -144,6 +195,20 @@ export function taprootSighash(tx, prevouts) { const encodePubkeyPolicy = (pub33) => concatBytes(Uint8Array.of(0x00), pub33); +// Serialize any user-facing VtxoPolicy (mirror of proto.js decodePolicy). +export function encodePolicy(p) { + const user = asBytes(p.userPubkey); + if (p.type === 'pubkey') return encodePubkeyPolicy(user); + if (p.type === 'serverHtlcSend') { + return concatBytes(Uint8Array.of(0x01), user, asBytes(p.paymentHash), u32le(p.htlcExpiry)); + } + if (p.type === 'serverHtlcRecv') { + return concatBytes(Uint8Array.of(0x02), user, asBytes(p.paymentHash), + u32le(p.htlcExpiry), u16le(p.htlcExpiryDelta)); + } + throw new Error('unsupported vtxo policy type ' + p.type); +} + // GenesisTransition::Arkoor const encodeArkoorTransition = ({ cosigners, tapTweak, signature }) => concatBytes( Uint8Array.of(0x02), @@ -183,12 +248,19 @@ function encodeVtxo({ amountSat, expiryHeight, serverPubkey, exitDelta, anchorPo // Build everything for a checkpointed single-input arkoor send. // input: decoded vtxo (from decodeVtxo, with _raw), owned by keys.vtxo -// outputs: [{ amountSat, userPubkey (33B) }...] — destination first, change last +// outputs: [{ amountSat, userPubkey (33B) }...] or [{ amountSat, policy }...] +// — destination first, change last export function buildArkoorSend({ input, outputs, serverPubkey, vtxoKeys }) { const userPub = vtxoKeys.pubkey; if (hex.encode(userPub) !== input.policy.userPubkey) throw new Error('input not owned by our key'); - const inputTaproot = pubkeyPolicyTaproot(userPub, serverPubkey, input.exitDelta); + // normalize plain-pubkey outputs to policy form + const outs = outputs.map((o) => ({ + amountSat: o.amountSat, + policy: o.policy || { type: 'pubkey', userPubkey: hex.encode(o.userPubkey) }, + })); + + const inputTaproot = policyTaproot(input.policy, serverPubkey, input.exitDelta); const checkpointTaproot = checkpointPolicyTaproot(userPub, serverPubkey, input.expiryHeight); // checkpoint tx: spends the input vtxo, one output per destination @@ -197,15 +269,15 @@ export function buildArkoorSend({ input, outputs, serverPubkey, vtxoKeys }) { version: 3, locktime: 0, inputs: [{ prevout: input.point.raw, sequence: 0 }], outputs: [ - ...outputs.map((o) => ({ valueSat: o.amountSat, scriptPubKey: checkpointTaproot.scriptPubKey })), + ...outs.map((o) => ({ valueSat: o.amountSat, scriptPubKey: checkpointTaproot.scriptPubKey })), { valueSat: 0, scriptPubKey: P2A_SCRIPT }, ], }; const checkpointTxid = txid(checkpointTx); // one arkoor tx per output, spending checkpoint:vout - const arkoorTxs = outputs.map((o, vout) => { - const destTaproot = pubkeyPolicyTaproot(o.userPubkey, serverPubkey, input.exitDelta); + const arkoorTxs = outs.map((o, vout) => { + const destTaproot = policyTaproot(o.policy, serverPubkey, input.exitDelta); return { version: 3, locktime: 0, inputs: [{ prevout: outpointBytes(checkpointTxid, vout), sequence: 0 }], @@ -225,7 +297,7 @@ export function buildArkoorSend({ input, outputs, serverPubkey, vtxoKeys }) { // taptweaks per signature (bark: taptweak_at) const tweaks = [inputTaproot.tapTweak, ...arkoorTxs.map(() => checkpointTaproot.tapTweak)]; - return { checkpointTx, checkpointTxid, arkoorTxs, sighashes, tweaks, inputTaproot, checkpointTaproot }; + return { outputs: outs, checkpointTx, checkpointTxid, arkoorTxs, sighashes, tweaks, inputTaproot, checkpointTaproot }; } // "arkoor cosign attestation " — 32-byte prefix @@ -236,50 +308,54 @@ export function cosignAttestation(inputVtxoIdRaw, outputs, vtxoPrivkey) { ATTESTATION_PREFIX, inputVtxoIdRaw, u32le(outputs.length), - ...outputs.flatMap((o) => [u64le(o.amountSat), encodePubkeyPolicy(o.userPubkey)]), + ...outputs.flatMap((o) => [u64le(o.amountSat), encodePolicy(o.policy)]), )); return schnorr.sign(msg, vtxoPrivkey); } -// full MuSig2 ceremony over the ASP's RequestArkoorCosign -export async function cosignWithServer(ark, build, { input, outputs, vtxoKeys, serverPubkey }) { - const nSigs = build.sighashes.length; +// One musig2 nonce per sighash, bound to it. +export const genUserNonces = (build, vtxoKeys) => + build.sighashes.map((sh) => musig2.nonceGen(vtxoKeys.pubkey, vtxoKeys.privkey, undefined, sh)); - // 1. nonces, bound to each sighash - const nonces = build.sighashes.map((sh) => - musig2.nonceGen(vtxoKeys.pubkey, vtxoKeys.privkey, undefined, sh)); - - // 2. request +// Serialize one ArkoorCosignRequest part for a built arkoor spend. +export function cosignPartBytes({ build, input, vtxoKeys, nonces }) { const part = pbWriter(); part.bytesField(1, input.point.raw); // vtxo id = outpoint bytes - for (const o of outputs) { + for (const o of build.outputs) { const dest = pbWriter(); dest.varintField(1, o.amountSat); - dest.bytesField(2, encodePubkeyPolicy(o.userPubkey)); + dest.bytesField(2, encodePolicy(o.policy)); part.bytesField(2, dest.finish()); } for (const n of nonces) part.bytesField(3, n.public); part.varintField(5, 1); // use_checkpoint = true - part.bytesField(6, cosignAttestation(input.point.raw, outputs, vtxoKeys.privkey)); - - const req = pbWriter(); - req.bytesField(1, part.finish()); - const respBytes = await grpcCall(ark, 'bark_server.ArkService/RequestArkoorCosign', req.finish()); + part.bytesField(6, cosignAttestation(input.point.raw, build.outputs, vtxoKeys.privkey)); + return part.finish(); +} - // 3. parse response - const serverNonces = [], serverPartials = []; +// Parse an ArkoorPackageCosignResponse into per-part nonce/partial lists. +export function parsePackageCosignResponse(respBytes) { + const parts = []; for (const { field, value } of pbFields(respBytes)) { if (field !== 1) continue; + const p = { serverNonces: [], serverPartials: [] }; for (const f of pbFields(value)) { - if (f.field === 1) serverNonces.push(f.value); - if (f.field === 2) serverPartials.push(f.value); + if (f.field === 1) p.serverNonces.push(f.value); + if (f.field === 2) p.serverPartials.push(f.value); } + parts.push(p); } + return parts; +} + +// Combine our partials with the server's into final schnorr signatures, +// verifying the server partial and the combined signature along the way. +export function combineCosign({ build, nonces, serverResp, vtxoKeys, serverPubkey }) { + const nSigs = build.sighashes.length; + const { serverNonces, serverPartials } = serverResp; if (serverNonces.length !== nSigs || serverPartials.length !== nSigs) { throw new Error(`bad cosign response: ${serverNonces.length} nonces, ${serverPartials.length} partials, wanted ${nSigs}`); } - - // 4. combine: session per sighash with the taptweak as x-only tweak const finalSigs = []; for (let i = 0; i < nSigs; i++) { const aggNonce = musig2.nonceAggregate([nonces[i].public, serverNonces[i]]); @@ -306,9 +382,20 @@ export async function cosignWithServer(ark, build, { input, outputs, vtxoKeys, s return finalSigs; } +// full MuSig2 ceremony over the ASP's RequestArkoorCosign +export async function cosignWithServer(ark, build, { input, vtxoKeys, serverPubkey }) { + const nonces = genUserNonces(build, vtxoKeys); + const req = pbWriter(); + req.bytesField(1, cosignPartBytes({ build, input, vtxoKeys, nonces })); + const respBytes = await grpcCall(ark, 'bark_server.ArkService/RequestArkoorCosign', req.finish()); + const [serverResp] = parsePackageCosignResponse(respBytes); + if (!serverResp) throw new Error('empty cosign response'); + return combineCosign({ build, nonces, serverResp, vtxoKeys, serverPubkey }); +} + // assemble the final signed Vtxo<Full> bytes for output `idx` -export function buildSignedVtxoBytes({ input, outputs, build, finalSigs, serverPubkey, idx }) { - const o = outputs[idx]; +export function buildSignedVtxoBytes({ input, build, finalSigs, serverPubkey, idx }) { + const o = build.outputs[idx]; // NB nb_outputs on the wire counts own output + other_outputs — the P2A // fee anchor is excluded (bark: other_outputs.len() + 1). const checkpointOthers = build.checkpointTx.outputs.filter((out, i) => @@ -346,7 +433,7 @@ export function buildSignedVtxoBytes({ input, outputs, build, finalSigs, serverP inputGenesisRaw: raw.bytes.slice(raw.itemsStart, raw.itemsEnd), inputGenesisCount: raw.nItems, newItems: [checkpointItem, arkoorItem], - policyBytes: encodePubkeyPolicy(o.userPubkey), + policyBytes: encodePolicy(o.policy), pointRaw: outpointBytes(txid(build.arkoorTxs[idx]), 0), }); } diff --git a/src/ark/validate.js b/src/ark/validate.js index d80ba50..f7aecb9 100644 --- a/src/ark/validate.js +++ b/src/ark/validate.js @@ -22,11 +22,13 @@ const fail = (msg) => { throw new VtxoValidationError(msg); }; // chain: an object with getTxHex(txid) -> hex|null and tipHeight() -> number, // plus getTxStatus(txid) -> {confirmed, block_height} (esplora semantics) -export async function validateVtxo(vtxo, { serverPubkey, chain, expectPubkeys }) { +// allowPolicies: acceptable policy types (default plain pubkey only; lightning +// flows pass ['serverHtlcSend'] / ['serverHtlcRecv'] for their HTLC vtxos) +export async function validateVtxo(vtxo, { serverPubkey, chain, expectPubkeys, allowPolicies = ['pubkey'] }) { const serverHex = hex.encode(serverPubkey); // -- structural -- - if (vtxo.policy.type !== 'pubkey') fail(`unsupported policy: ${vtxo.policy.type}`); + if (!allowPolicies.includes(vtxo.policy.type)) fail(`unsupported policy: ${vtxo.policy.type}`); if (vtxo.serverPubkey !== serverHex) fail('vtxo server pubkey does not match ours'); if (expectPubkeys && !expectPubkeys.includes(vtxo.policy.userPubkey)) { fail('vtxo not paid to one of our keys'); diff --git a/src/features/ark.js b/src/features/ark.js index 08e44de..9b057b1 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -8,6 +8,7 @@ import { hex, base32nopad, bech32 } from '@scure/base'; import { sha256 } from '@noble/hashes/sha256'; import { ArkManager } from '../ark/manager.js'; import { boardFee } from '../ark/board.js'; +import { maybeBolt11, lnSendFee } from '../ark/lightning.js'; import { decodeVtxo, getVtxoStatus, VTXO_STATE_SPENT, concatBytes } from '../ark/proto.js'; import { signedExitTxs, buildBumpChild, buildExitClaim, submitPackage } from '../ark/exit.js'; import { utxoId } from '../wallet.js'; @@ -94,6 +95,7 @@ export function mergeArkStates(a, b) { out.gifts = [...gifts.values()]; out.mailboxCheckpoint = Math.max(a.mailboxCheckpoint || 0, b.mailboxCheckpoint || 0); out.nextKeyIndex = Math.max(a.nextKeyIndex || 1, b.nextKeyIndex || 1); + out.nextLnRecvIndex = Math.max(a.nextLnRecvIndex || 0, b.nextLnRecvIndex || 0); out.receiveAckTs = Math.max(a.receiveAckTs || 0, b.receiveAckTs || 0); return out; } @@ -148,6 +150,8 @@ export function arkFeature(ctx) { arkTimer = null; if (ark) ark.stopMailboxStream(); stopNwcFunding(); + for (const timer of arkLnTimers.values()) clearInterval(timer); + arkLnTimers.clear(); ark = null; arkConnectPromise = null; } @@ -310,16 +314,220 @@ export function arkFeature(ctx) { ui.busy = false; render(); } + // ---- lightning via the ASP (captaind pays/holds invoices natively) ------- + // One poll timer per in-flight lightning action; cleared on settle. + const arkLnTimers = new Map(); + function stopArkLnPoll(id) { + const timer = arkLnTimers.get(id); + if (timer) { clearInterval(timer); arkLnTimers.delete(id); } + } + function pollArkLn(id, onSettle) { + stopArkLnPoll(id); + arkLnTimers.set(id, setInterval(async () => { + if (!ark) return; + const a = await ark.driveLn(id).catch(() => null); + if (a && ['done', 'failed'].includes(a.step)) { + stopArkLnPoll(id); + onSettle(a); + } + render(); + }, 2500)); + } + + // A bolt11 lands in Send (via the swaps feature's delegation hook, or + // directly when swaps is absent): take over when the ark balance can + // plausibly cover it, else decline so the Boltz path handles it. + function startArkLnPay(invoice, meta) { + const dec = maybeBolt11(invoice); + if (!dec || !arkAvailable() || wallet.watchOnly) return false; + const s = arkStateNow(); + const canCover = (sats) => + !!s && (s.vtxos || []).some((v) => v.state === 'spendable' && v.amountSat >= sats); + if (!canCover(dec.amountSat || 1)) return false; + ui.arkLnPay = { invoice, meta: meta || null, amountSat: dec.amountSat, amount: '', feeSat: null, status: 'quote' }; + ui.sendError = ''; + render(); + connectArk().then(async (mgr) => { + const p = ui.arkLnPay; + if (!p || p.invoice !== invoice) return; + if (p.amountSat) { + // real quote: smallest vtxo that covers amount + its expiry-based fee + const tip = await mgr.chain.tipHeight(); + const candidates = mgr.vtxos().filter((v) => v.state === 'spendable') + .sort((a, b) => a.amountSat - b.amountSat); + let fee = null; + for (const v of candidates) { + const f = lnSendFee(p.amountSat, mgr.info.lnSendFees, [v], tip); + if (v.amountSat >= p.amountSat + f) { fee = f; break; } + } + if (fee == null) { + // can't cover once fees are in — hand off to the Boltz path + ui.arkLnPay = null; + if (!ctx.hook('startLnPayBoltz', invoice, meta)) { ui.sendError = t('arkNotConnected'); } + render(); + return; + } + p.feeSat = fee; + } + p.status = 'ready'; + render(); + }).catch((e) => { + if (ui.arkLnPay && ui.arkLnPay.invoice === invoice) { ui.sendError = e.message; render(); } + }); + return true; + } + + async function doArkLnPay() { + const p = ui.arkLnPay; + const sats = p.amountSat || ctx.parseAmount(p.amount, ctx.getUnit()); + if (!sats || sats <= 0) { ui.sendError = t('enterValidAmtForN', { n: 1 }); render(); return; } + ui.busy = true; ui.sendError = ''; render(); + try { + const mgr = await connectArk(); + const id = await mgr.payLnInvoice(p.invoice, { amountSat: sats }); + const settle = (a) => { + if (a.step === 'done') { + ui.arkLnPaid = { amountSat: a.amountSat, meta: p.meta }; + ui.arkLnPay = null; + } else if (ui.arkLnPay) { + ui.arkLnPay.status = 'ready'; + ui.sendError = a.error === 'payment failed' ? t('arkLnRefunded') : (a.error || t('arkLnPayFailed')); + } + render(); + }; + const a = mgr.lnAction(id); + if (['done', 'failed'].includes(a.step)) settle(a); + else { p.actionId = id; p.status = 'paying'; pollArkLn(id, settle); } + } catch (e) { + ui.sendError = e.message; + } + ui.busy = false; render(); + } + + function arkLnPayView() { + const u = ' ' + unitLabel(); + if (ui.arkLnPaid) { + const zap = ui.arkLnPaid.meta; + return h('div', { + class: 'card col', + style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', + onClick: () => { ui.arkLnPaid = null; ui.send = blankSend(); render(); }, + }, + h('div', { class: 'check-badge' }, '⚡'), + h('h2', { style: 'margin:0' }, zap ? t('arkZapSentTitle') : t('arkLnPaidTitle')), + zap && zap.name ? h('div', { class: 'small muted' }, zap.name) : null, + h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkLnPaid.amountSat) + u), + h('div', { class: 'small muted' }, t('tapToProceed'))); + } + const p = ui.arkLnPay; + if (!p) return null; + const zap = p.meta; + const row = (label, sats, bold) => h('div', { class: 'row between' + (bold ? '' : ''), style: bold ? 'font-weight:600' : '' }, + h('span', { class: bold ? '' : 'small muted' }, label), h('span', {}, fmtAmount(sats) + u)); + const total = (p.amountSat || 0) + (p.feeSat || 0); + return h('div', { class: 'col', style: 'gap:12px' }, + h('div', { class: 'card col', style: 'gap:10px' }, + h('h3', { style: 'margin:0' }, '⚡ ' + (zap ? t('lnZapReviewTitle') : t('lnPayTitle'))), + h('div', { class: 'small muted' }, t('arkLnPayVia')), + zap && (zap.name || zap.address) ? h('div', { class: 'small muted', style: 'word-break:break-all' }, zap.name || zap.address) : null, + zap && zap.comment ? h('div', { class: 'small faint', style: 'font-style:italic;word-break:break-word' }, '“' + zap.comment + '”') : null, + p.status === 'quote' + ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('lnQuoting'))) + : null, + p.amountSat == null && p.status !== 'quote' + ? h('div', { class: 'input-group' }, + h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: p.amount, + onInput: (e) => { p.amount = e.target.value; } }), + h('div', { style: 'display:flex;align-items:center' }, unitTag())) + : null, + p.amountSat != null ? row(t('lnPayAmount'), p.amountSat) : null, + p.feeSat != null && p.feeSat > 0 ? row(t('arkLnFee'), p.feeSat) : null, + p.amountSat != null && p.feeSat != null + ? [h('div', { style: 'border-top:1px solid var(--line, #ddd);margin:2px 0' }), row(t('lnPayTotal'), total, true)] + : null), + ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null, + p.status === 'paying' + ? h('div', { class: 'card row gap6', style: 'align-items:center' }, + h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkLnPaying'))) + : h('button', { class: 'btn-primary btn-block', disabled: !!ui.busy || p.status === 'quote', onClick: doArkLnPay }, + ui.busy ? h('span', { class: 'spinner' }) : t('lnPayConfirm')), + p.status !== 'paying' + ? h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkLnPay = null; ui.sendError = ''; ui.send = blankSend(); render(); } }, t('back')) + : null); + } + + async function doArkLnInvoice() { + const sats = parseInt((ui.arkLnRecvAmt || '').trim(), 10); + if (!sats) return; + ui.arkBusy = 'lninvoice'; ui.arkError = ''; render(); + try { + const mgr = await connectArk(); + const a = await mgr.createLnInvoice(sats); + ui.arkLnRecvId = a.id; + pollArkLn(a.id, () => render()); // 'done' -> celebration via unseenReceives + } catch (e) { + ui.arkError = e.message; + } + ui.arkBusy = null; render(); + } + + // Lightning-receive block on the Ark receive pane: amount form -> invoice QR. + function arkLnReceiveSection() { + if (!ark || !ark.info || !ark.info.htlcExpiryDelta) return null; // server without lightning + const s = arkStateNow(); + let rec = ui.arkLnRecvId ? ((s && s.actions) || []).find((a) => a.id === ui.arkLnRecvId) : null; + if (rec && ['done', 'failed'].includes(rec.step)) { + // settled (celebration takes over) or dead — reset to the form + if (rec.step === 'failed' && rec.error && rec.error !== 'canceled') ui.arkError = rec.error; + ui.arkLnRecvId = null; + ui.arkLnRecvAmt = ''; + rec = null; + } + const divider = { class: 'col', style: 'width:100%;gap:8px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:14px' }; + if (rec) { + let svg = null; + try { svg = qrSvg(rec.invoice.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {} + return h('div', divider, + h('div', { class: 'small muted', style: 'text-align:center' }, t('lnReceiveAwaiting')), + svg ? h('div', { style: 'align-self:center', html: svg }) : null, + h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, rec.invoice), + h('div', { class: 'row gap6', style: 'justify-content:center' }, copyBtn(rec.invoice, t('copyInvoice'))), + h('div', { class: 'row gap6', style: 'align-items:center;justify-content:center' }, + h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('lnReceiveWatching'))), + rec.step === 'awaiting' + ? h('button', { class: 'btn-ghost btn-block', onClick: async () => { + stopArkLnPoll(rec.id); + try { await ark.cancelLnInvoice(rec.id); } catch {} + ui.arkLnRecvId = null; ui.arkLnRecvAmt = ''; render(); + } }, t('arkLnCancelInvoice')) + : null); + } + return h('div', divider, + h('div', { class: 'small muted', style: 'text-align:center' }, t('arkLnInvoiceIntro')), + h('div', { class: 'input-group' }, + h('input', { + type: 'number', inputmode: 'numeric', min: '0', + placeholder: t('amountSatsLabel'), + value: ui.arkLnRecvAmt || '', + onInput: (e) => { ui.arkLnRecvAmt = e.target.value; render(); }, + }), + h('button', { class: 'btn-sm', disabled: !!ui.arkBusy || !parseInt(ui.arkLnRecvAmt || '', 10), onClick: doArkLnInvoice }, + ui.arkBusy === 'lninvoice' ? h('span', { class: 'spinner sm' }) : '⚡ ' + t('lnCreateInvoice')))); + } + function arkHistoryItem(m) { - const incoming = !['send', 'offboard', 'exit'].includes(m.type); + const incoming = !['send', 'offboard', 'exit', 'ln-send'].includes(m.type); const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') + : m.type === 'ln-send' ? t('arkLnPaidHistory') : m.type === 'ln-receive' ? t('arkLnReceivedHistory') : m.type === 'offboard' ? t('arkOffboarded') : m.type === 'exit' ? t('arkExited') : t('sent'); return h( 'div', { class: 'item', style: 'cursor:pointer', onClick: () => { ui.arkMoveDetail = m.id; render(); } }, // Ark mark in the (direction-colored) circle carries the rail; the label // + signed amount carry direction, so no redundant "Ark" text chip. - h('div', { class: `ico ${incoming ? 'in' : 'out'}`, html: ARK_MARK(15) }), + m.type.startsWith('ln-') + ? h('div', { class: `ico ${incoming ? 'in' : 'out'}` }, '⚡') + : h('div', { class: `ico ${incoming ? 'in' : 'out'}`, html: ARK_MARK(15) }), h('div', { class: 'grow' }, h('div', { class: 'row gap6' }, label, @@ -331,8 +539,9 @@ export function arkFeature(ctx) { } function arkMoveDetailView(m) { - const incoming = !['send', 'offboard', 'exit'].includes(m.type); + const incoming = !['send', 'offboard', 'exit', 'ln-send'].includes(m.type); const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') + : m.type === 'ln-send' ? t('arkLnPaidHistory') : m.type === 'ln-receive' ? t('arkLnReceivedHistory') : m.type === 'offboard' ? t('arkOffboarded') : m.type === 'exit' ? t('arkExited') : t('sent'); const row = (k, v) => h('div', { class: 'row between', style: 'gap:12px' }, h('span', { class: 'small muted', style: 'flex-shrink:0' }, k), h('span', { class: 'small', style: 'text-align:right;word-break:break-all' }, v)); @@ -424,7 +633,7 @@ export function arkFeature(ctx) { (() => { const counts = {}; for (const a of pendingActions) { if (a.type !== 'exit') counts[a.type] = (counts[a.type] || 0) + 1; } - const LABEL = { board: 'arkOpsBoard', send: 'arkOpsSend', refresh: 'arkOpsRefresh', offboard: 'arkOpsOffboard' }; + const LABEL = { board: 'arkOpsBoard', send: 'arkOpsSend', refresh: 'arkOpsRefresh', offboard: 'arkOpsOffboard', 'ln-pay': 'arkOpsLnPay', 'ln-recv': 'arkOpsLnRecv' }; const parts = Object.entries(counts).map(([type, n]) => t(LABEL[type] || 'arkPendingActions', { n })); return parts.length ? h('div', { class: 'small muted' }, parts.join(' · ')) : null; })(), @@ -919,7 +1128,9 @@ export function arkFeature(ctx) { canBoard ? h('div', { class: 'small faint', style: 'text-align:center' }, t('arkBoardAvailable', { n: fmtAmount(wallet.spendable) + ' ' + unitLabel() })) : h('div', { class: 'small faint', style: 'text-align:center' }, t('arkBoardNoFunds', { n: minBoard.toLocaleString() })), - ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null) + ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null), + // Lightning receive straight into the ark balance (ASP-native swap) + arkLnReceiveSection() ); } throw new Error('unreachable'); @@ -1083,6 +1294,10 @@ export function arkFeature(ctx) { return h('div', { class: 'small faint' }, t('arkSendHint')); }, interceptReview(s) { + if (s.recipients.length === 1) { + const inv = (s.recipients[0].address || '').trim().replace(/^lightning:/i, ''); + if (maybeBolt11(inv) && startArkLnPay(inv)) return true; + } if (s.recipients.length === 1 && isArkAddress(s.recipients[0].address)) { if (!arkAvailable()) throw new Error(t('arkNotConnected')); const sats = ctx.parseAmount(s.recipients[0].amount, ctx.getUnit()); @@ -1094,13 +1309,22 @@ export function arkFeature(ctx) { return false; }, sendView() { + if (ui.arkLnPaid || ui.arkLnPay) return arkLnPayView(); if (ui.arkZapped || ui.arkZap) return arkZapView(); if (ui.arkSent || ui.arkSend) return arkSendReview(); return null; }, + // A bolt11 pays from the ark balance when it can cover it. Reached via the + // swaps feature's delegation (its matcher runs first) or directly in + // builds without the swaps feature. + startArkLnPay(invoice, meta) { return startArkLnPay(invoice, meta); }, // An npub pasted into Send becomes an ark zap (needs the nostr seam from - // the sync feature and a connected-able ark). + // the sync feature and a connected-able ark). A bolt11 is handled here + // only in builds without the swaps feature (whose matcher runs first and + // delegates back via the startArkLnPay hook). matchSendText(text) { + const inv = (text || '').trim().replace(/^lightning:/i, ''); + if (maybeBolt11(inv)) return startArkLnPay(inv); const pk = npubToHex(text); if (!pk || !arkAvailable() || !wallet.nostrFetch) return false; ui.arkZap = { npub: String(text).trim(), pk, amount: '', comment: '', status: 'lookup' }; @@ -1115,7 +1339,7 @@ export function arkFeature(ctx) { const s = arkStateNow(); if (!s) return []; return (s.movements || []) - .filter((m) => ['receive', 'send', 'board', 'offboard', 'exit'].includes(m.type) && m.status === 'complete') + .filter((m) => ['receive', 'send', 'board', 'offboard', 'exit', 'ln-send', 'ln-receive'].includes(m.type) && m.status === 'complete') .map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) })); }, historyDetail() { diff --git a/src/features/swaps.js b/src/features/swaps.js index 091ca32..bad823c 100644 --- a/src/features/swaps.js +++ b/src/features/swaps.js @@ -132,7 +132,11 @@ export function swapsFeature(ctx) { line(t('status'), swap.status === 'claimed' ? 'Settled ✓' : swap.status)); } - async function startLnSend(invoice, meta = null) { + async function startLnSend(invoice, meta = null, skipArk = false) { + // The ark feature pays bolt11 straight from the ark balance when it can + // cover it (the ASP is the swap counterparty) — cheaper and instant, so + // it gets first refusal before the on-chain Boltz submarine path. + if (!skipArk && ctx.hook('startArkLnPay', invoice, meta)) return; ui.sendError = ''; ui.lnSend = null; ui.lnSent = null; ui.lnPayMeta = meta; ui.lnSendBusy = true; render(); try { ui.lnSend = await swaps.quoteSubmarine(invoice); } catch (e) { ui.sendError = e.message; } @@ -228,5 +232,8 @@ export function swapsFeature(ctx) { // the review as a zap (recipient name, comment). Returns true so the hook // dispatcher stops here. startLnPay(invoice, meta) { startLnSend(invoice, meta || null); return true; }, + // The ark feature falls back here when the ark balance can't cover an + // invoice after fees — skipArk avoids bouncing back and forth. + startLnPayBoltz(invoice, meta) { startLnSend(invoice, meta || null, true); return true; }, }; } diff --git a/src/i18n.js b/src/i18n.js index 04e7e06..82a397b 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -427,6 +427,18 @@ const STR = { arkOpsSend: '{n} send(s) finishing up', arkOpsRefresh: '{n} refresh(es) waiting for the next round', arkOpsOffboard: '{n} withdrawal(s) confirming', + arkOpsLnPay: '{n} Lightning payment(s) in flight', + arkOpsLnRecv: '{n} Lightning invoice(s) outstanding', + arkLnPayVia: 'Instant, from your Ark balance', + arkLnFee: 'Ark fee', + arkLnPaying: 'Paying invoice…', + arkLnPayFailed: 'Payment failed', + arkLnRefunded: 'Payment failed — funds returned to your Ark balance.', + arkLnPaidTitle: 'Paid!', + arkLnInvoiceIntro: 'Or receive over Lightning, straight into your Ark balance.', + arkLnPaidHistory: 'Paid Lightning invoice', + arkLnReceivedHistory: 'Received over Lightning', + arkLnCancelInvoice: 'Cancel invoice', arkExited: 'Exited on-chain', arkExitHistory: 'Ark exit', arkRefreshStarted: 'Refresh submitted — completes with the next round.', |
