From 78789e0b2eaf91e2de6b4e488324fbec0fcefb9d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 31 Jul 2026 00:49:21 -0700 Subject: Add Ark <-> Lightning via the ASP (captaind-native submarine swaps) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ASP is the HTLC counterparty (second.tech's protocol), so no external swap provider is needed: paying a bolt11 converts pubkey vtxos into ServerHtlcSend vtxos the server can only claim by revealing the invoice preimage; receiving grants ServerHtlcRecv vtxos claimed by revealing ours. Failure paths cosign the HTLC back (revocation) — proven end-to-end against regtest captaind, which independently derives the HTLC taproots. - ark/lightning.js: bolt11 decode, lightning fee schedules, all pay/receive gRPC-web wrappers (cosign, initiate, status, revocation, hold-invoice receive flow, anti-DoS attestation) - ark/send.js: arkoor builder generalized to policy outputs (pubkey / serverHtlcSend / serverHtlcRecv), two-leaf taproot trees, HTLC clause scripts, reusable per-part cosign helpers for package RPCs - ark/manager.js: ln-pay and ln-recv crash-safe action state machines (deterministic receive preimages from the seed, chain 5), mailbox lightning notifications, unpayable-invoice -> instant revocation - proto.js: lightning ArkInfo fields + mailbox lightning message decode - validate/refresh: policy-aware vtxo validation and reconstruction - features: bolt11 routes via ark when the balance covers it (Boltz stays the fallback), lightning-invoice receive on the Ark pane - tools/ark/ln-test.js: harness proving HTLC cosign + revocation against captaind; manager-test.js updated for the consolidated bc-container chain Co-Authored-By: Claude Fable 5 --- src/features/ark.js | 238 ++++++++++++++++++++++++++++++++++++++++++++++++-- src/features/swaps.js | 9 +- 2 files changed, 239 insertions(+), 8 deletions(-) (limited to 'src/features') 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; }, }; } -- cgit v1.2.3