// Ark feature — off-chain payments via an ASP (Second's bark/captaind // protocol, spoken natively by ../ark). Receive address + boarding, instant // sends, history entries, refresh/consolidation, server settings. import * as btc from '@scure/btc-signer'; import { HDKey } from '@scure/bip32'; import { hex, base32nopad, bech32 } from '@scure/base'; import { sha256 } from '@noble/hashes/sha256'; import { ArkManager } from '../ark/manager.js'; import { loadBg, saveBg, buildBg } from '../nwc-bg.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'; import { getNetwork, setNetwork, arkPresets, getArkProviderId, setArkProviderId, getArkCustom, setArkCustom, getArkConfig, } from '../api.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; import { shortAddr, shortTxid, timeAgo, ARK_ICON, ARK_MARK } from '../format.js'; // t?ark1… bech32m — an Ark address for this or another ASP. export function isArkAddress(a) { return /^t?ark1[a-z0-9]{20,}$/i.test((a || '').trim()); } // Wallet storage/key helpers for this feature, installed onto the core // wallet instance so a build without the feature ships none of it. export function installArkWallet(wallet) { if (wallet.loadArkState) return; // already installed Object.assign(wallet, { // ---- Ark support ------------------------------------------------------- // Persisted ArkManager state (vtxos, in-flight action checkpoints, movement // history — no secrets: vtxo keys are re-derived from the seed). // Ark state is per WALLET *and* per ASP: vtxos are cosigned by one // specific server, so showing another server's coins (or resuming its // in-flight board) is meaningless and alarming. Switching providers must // therefore switch state, and switching back must restore it. _arkNs(url) { // short, stable id for an ASP endpoint let h = 5381; for (const c of String(url || '')) h = ((h * 33) ^ c.charCodeAt(0)) >>> 0; return h.toString(36); }, _arkKey(url) { return this._cacheKey() + ':ark:' + this._arkNs(url); }, _arkLegacyKey() { return this._cacheKey() + ':ark'; }, // Who really cosigned this state's coins? A vtxo names its server in its // own bytes, which is cryptographic fact — unlike the stamp, which is // just something we wrote and could have written wrongly. _arkVtxoOwner(st) { const v = (st?.vtxos || []).find((x) => x.bytes); if (!v) return null; try { return decodeVtxo(hex.decode(v.bytes)).serverPubkey; } catch { return null; } }, // Park state under the server that actually owns it, until the URL for // that server is selected and can adopt it. _arkHoldKey(pubkey) { return this._cacheKey() + ':ark:pk:' + String(pubkey).slice(0, 16); }, loadArkState(url) { // NB deliberately no fallback to the legacy key: the UI renders from // this before any server is contacted, so returning another ASP's state // here is exactly how you get a phantom balance. Pre-namespacing state // is claimed via adoptArkState() once we know who cosigned it. try { const cur = localStorage.getItem(this._arkKey(url)); if (!cur) return null; const st = JSON.parse(cur); // Self-repair: an earlier build could stamp state with whichever // server happened to be connected, filing one ASP's coins under // another. The vtxos themselves settle it — if they disagree with the // stamp, this state is misfiled. Move it to its real owner and show // nothing here. const owner = this._arkVtxoOwner(st); if (owner && st.serverPubkey && owner !== st.serverPubkey) { st.serverPubkey = owner; try { localStorage.setItem(this._arkHoldKey(owner), JSON.stringify(st)); localStorage.removeItem(this._arkKey(url)); } catch {} return null; } return st; } catch { return null; } }, // Offer up pre-namespacing state, but only to the ASP that actually // cosigned it. Its owner is read from the state's own stamp, or failing // that decoded straight out of a vtxo — the vtxo wire format carries the // server pubkey, so this needs no network call and cannot guess wrong. adoptArkState(serverPubkey, url) { try { // state parked by the self-repair above belongs to exactly one server const held = localStorage.getItem(this._arkHoldKey(serverPubkey)); if (held) { const st = JSON.parse(held); this.saveArkState(st, url); localStorage.removeItem(this._arkHoldKey(serverPubkey)); return st; } const raw = localStorage.getItem(this._arkLegacyKey()); if (!raw) return null; const st = JSON.parse(raw); let owner = st.serverPubkey || null; if (!owner) { const v = (st.vtxos || []).find((x) => x.bytes); if (v) { try { owner = decodeVtxo(hex.decode(v.bytes)).serverPubkey; } catch {} } } if (owner && owner !== serverPubkey) return null; // belongs to another ASP if (!owner) return null; // can't prove ownership: leave it st.serverPubkey = owner; this.saveArkState(st, url); localStorage.removeItem(this._arkLegacyKey()); // now safely namespaced return st; } catch { return null; } }, saveArkState(state, url) { try { localStorage.setItem(this._arkKey(url), JSON.stringify(state)); } catch {} }, }); } // A slim projection of ark state for the sync snapshot — the sync relay caps // events at 64 KB and the full state (deep-genesis vtxo bytes + done-action // hex) blew past it, so publishes silently failed and nothing synced. Another // device needs, per vtxo: id/amount/state (+ bytes ONLY for spendable/pending, // which it might spend/exit); the movement log (capped) for ark history; and // done board/offboard/exit actions (stripped of hex) so on-chain rows still // label. In-flight actions and their signing material stay device-local. export function slimArkForSync(s) { if (!s) return null; const HEAVY = ['bytes', 'destBytes', 'changeBytes', 'vtxoBytes', 'txHex', 'fundingTxHex', 'outputVtxos']; return { v: s.v, serverPubkey: s.serverPubkey, mailboxCheckpoint: s.mailboxCheckpoint, nextKeyIndex: s.nextKeyIndex, receiveAckTs: s.receiveAckTs, // Spent stubs and done actions are pure history and grow forever — cap // them (newest kept) so the sync event's size is bounded for life. vtxos: (() => { const live = (s.vtxos || []).filter((v) => v.state !== 'spent'); const stubs = (s.vtxos || []).filter((v) => v.state === 'spent').slice(-200) .map((v) => ({ id: v.id, amountSat: v.amountSat, state: 'spent', keyIndex: v.keyIndex, expiryHeight: v.expiryHeight })); return [...live, ...stubs]; })(), actions: (s.actions || []) .filter((a) => a.step === 'done' && ['board', 'offboard', 'exit'].includes(a.type)) .slice(-50) .map((a) => { const c = { ...a }; for (const k of HEAVY) delete c[k]; return c; }), movements: (s.movements || []).slice(-200).map((m, i, arr) => // Older rows only need to render a history line; the bolt11 (~400 chars // each) and preimage are what pushed snapshots past relay size limits. (arr.length - i <= 50 ? m : (({ invoice, preimage, ...rest }) => rest)(m))), gifts: s.gifts, }; } // Merge two ark states so devices can't clobber each other through the sync // snapshot: additive union of vtxos (a device keeps its own full copy, gains // ones it lacks), actions/movements/gifts union by id, counters take the max // (so two devices never reuse a key index). export function mergeArkStates(a, b) { if (!a) return b; if (!b) return a; // never fuse state from two different ASPs if (a.serverPubkey && b.serverPubkey && a.serverPubkey !== b.serverPubkey) return a; const out = { ...a }; // ADDITIVE union only: bring in vtxos this device is missing (the whole // point — board/change coins that live only where they were created), but // NEVER let a remote snapshot change the state of a vtxo we already track. // Spend detection is each device's own job via reconcile() against the // server (the authority), so a stale-or-wrong "spent" on one device can't // propagate and permanently poison the others. A newly-merged-in vtxo that // was actually spent elsewhere gets caught by the reconcile on next connect // / send-intent, exactly like any other drift. const vtxos = new Map((a.vtxos || []).map((v) => [v.id, { ...v }])); for (const rv of b.vtxos || []) if (!vtxos.has(rv.id)) vtxos.set(rv.id, { ...rv }); out.vtxos = [...vtxos.values()]; const unionById = (x = [], y = []) => { const ids = new Set(x.map((i) => i.id)); return [...x, ...y.filter((i) => !ids.has(i.id))]; }; out.actions = unionById(a.actions, b.actions); out.movements = unionById(a.movements, b.movements).sort((m, n) => (m.ts || 0) - (n.ts || 0)); const gifts = new Map((a.gifts || []).map((g) => [g.id, { ...g }])); for (const rg of b.gifts || []) { const lg = gifts.get(rg.id); if (!lg) gifts.set(rg.id, { ...rg }); else { lg.claimed = lg.claimed || rg.claimed; lg.revoked = lg.revoked || rg.revoked; } } 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); out.serverPubkey = a.serverPubkey || b.serverPubkey || null; return out; } export function arkFeature(ctx) { const { h, ui, render, wallet, blankSend, fmtAmount, unitLabel, unitTag, copyBtn, toast, openExternal } = ctx; installArkWallet(wallet); // ark state storage lives outside the core wallet // Ride the encrypted sync snapshot so ark funds follow the seed across // devices. Board/change vtxo bytes exist only where they were created — a // second device can't otherwise see them. All devices share ONE replaceable // event slot (same seed -> same nostr key), so a naive publish can clobber // another device's coins. Anti-entropy fixes that: on receiving a snapshot, // union it with local; if it was missing coins we know (or brought coins we // didn't), re-publish the superset. The slot converges to the union no // matter who publishes when. wallet.registerCacheExtension({ domain: 'ark', // published as its own sync slot — see splitSnapshotDomains mergeAlways: true, // load() is a commutative merge — apply older snapshots too save: () => { const cfg = getArkConfig(); const s = cfg && wallet.loadArkState(cfg.ark); // tag the snapshot with the ASP it belongs to (see load()) return s ? { arkState: slimArkForSync(s), arkServer: wallet._arkNs(cfg.ark) } : {}; }, load: (d) => { if (!d.arkState) return; const cfg = getArkConfig(); if (!cfg) return; if (d.arkServer && d.arkServer !== wallet._arkNs(cfg.ark)) return; // A snapshot from an older build carries no tag, so the tag alone can't // be trusted — ask the vtxos who cosigned them. If they belong to a // different ASP (or we can't yet prove they belong to this one), park // the snapshot under its real owner instead of merging it in. Merging // blind is exactly how another server's balance appears here. const owner = d.arkState.serverPubkey || wallet._arkVtxoOwner(d.arkState); const mine = (ark && ark.info && ark.info.serverPubkey) || (wallet.loadArkState(cfg.ark) || {}).serverPubkey || null; if (owner && owner !== mine) { try { localStorage.setItem(wallet._arkHoldKey(owner), JSON.stringify({ ...d.arkState, serverPubkey: owner })); } catch {} // `mine` is null on a FRESH restore (no local state, not yet // connected), so even our own snapshot lands in the hold. Connecting // is what resolves it: the manager learns the server's pubkey and // adopts the held state if it matches — a seed imported on a new // device would otherwise show no ark balance until some manual poke. if (!mine) setTimeout(() => maybeInitArk(), 0); return; } const local = wallet.loadArkState(cfg.ark); const merged = mergeArkStates(local, d.arkState); wallet.saveArkState(merged, cfg.ark); const mergedN = (merged.vtxos || []).length; const remoteN = (d.arkState.vtxos || []).length; const localN = (local && local.vtxos || []).length; // We know vtxos this snapshot lacked — push our superset back up so the // sender (and the shared slot) learns them. Guarded by mergedN>remoteN so // it can't loop once every device has converged to the union. if (mergedN > remoteN) { try { wallet.saveCache(); } catch {} } // The merge brought vtxos our live manager doesn't have — (re)connect so // the balance actually shows. const novel = mergedN > localN || (mergedN && (!ark || !ark.state)); if (mergedN && novel) setTimeout(() => maybeInitArk(), 0); }, }); // One manager per open wallet; null when Ark is off in Settings, watch-only, // or not yet dialed (lazy: fresh wallets connect on first use). let ark = null; let arkTimer = null; let arkConnectPromise = null; let arkInitGen = 0; // guards against a stale init() resolving after a wallet switch function stopArk() { arkInitGen++; if (arkTimer) clearInterval(arkTimer); arkTimer = null; if (ark) ark.stopMailboxStream(); stopNwcFunding(); for (const timer of arkLnTimers.values()) clearInterval(timer); arkLnTimers.clear(); ark = null; arkConnectPromise = null; } function arkAvailable() { return !!getArkConfig() && !wallet.watchOnly && !!wallet.account; } function arkWanted() { const cfg = getArkConfig(); const st = cfg && wallet.loadArkState(cfg.ark); return !!(st && ((st.vtxos || []).length || (st.movements || []).length || (st.actions || []).some((a) => !['done', 'failed'].includes(a.step)))); } function initArk() { stopArk(); ui.arkError = ''; lastAutoInit = Date.now(); if (arkAvailable() && arkWanted()) connectArk().catch(() => {}); } // Automatic re-inits (a synced snapshot arriving) must not restart a // connection that is already in flight, or retry in a tight loop when the // server is unreachable: each one calls stopArk() first, so the settings // card's "connecting" indicator flickers on every snapshot. User-driven // calls to initArk() stay immediate. let lastAutoInit = 0; function maybeInitArk() { if (ark || arkConnectPromise) return; if (Date.now() - lastAutoInit < 20000) return; initArk(); } function connectArk() { if (ark) return Promise.resolve(ark); if (arkConnectPromise) return arkConnectPromise; if (!arkAvailable()) return Promise.reject(new Error(t('arkNotConnected'))); const cfg = getArkConfig(); const gen = arkInitGen; const mgr = new ArkManager({ account: wallet.account(), storage: { load: () => wallet.loadArkState(cfg.ark), adopt: (serverPubkey) => wallet.adoptArkState(serverPubkey, cfg.ark), // merge-on-save: the MANAGER is authoritative for the state of the // vtxos it tracks (its reconcile/spends must persist), so its state // is the first arg and wins; storage only contributes vtxos the // manager hasn't loaded yet (a snapshot merged in from another device // between init and the next reinit). saveCache() then carries ark // state into the snapshot -> debounced nostr publish. save: (s) => { wallet.saveArkState(mergeArkStates(s, wallet.loadArkState(cfg.ark)), cfg.ark); try { wallet.saveCache(); } catch {} }, }, arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), onUpdate: () => render(), }); ui.arkError = ''; arkConnectPromise = mgr.init().then(() => { if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect ark = mgr; announceArkAddress(mgr); // ark zaps: tell nostr where our mailbox lives startNwcFunding(mgr); // NWC bridge: honor funding requests within the allowance // Publish this device's ark state to its per-device sync slot on connect. // Cross-device sync only re-published on a state CHANGE (a send/board), so // an idle device never shared its coins — another device would never see // them. Publishing on connect guarantees every device's ark balance // reaches the relay (and gets merged) without needing a transaction first. if (mgr.state && (mgr.state.vtxos || []).length) { try { wallet.saveCache(); } catch {} } const tick = () => mgr.sync().catch(() => {}).then(() => driveExits(mgr)).catch(() => {}); tick(); // Reconcile once on connect: a vtxo synced in from another device (or // one this device held while a spend happened elsewhere) is checked // against the server, so a stale spendable is caught here rather than // only at send time. mgr.reconcile().catch(() => {}); mergeBgWorkerState(mgr).catch(() => {}); // absorb what the SW did while we were closed // Receives arrive in real time over the mailbox stream; the poll is the // fallback and what drives in-flight boards/refreshes forward. mgr.startMailboxStream(); arkTimer = setInterval(() => { if (ark === mgr) tick(); }, getNetwork() === 'regtest' ? 5000 : 30000); render(); return mgr; }).catch((e) => { if (gen === arkInitGen) { ui.arkError = e.message; render(); } throw e; }).finally(() => { arkConnectPromise = null; }); render(); return arkConnectPromise; } // The current ark state for READ-ONLY rendering: the live manager if it's // connected, else the persisted state straight from storage. Lets the // balance and history icons paint immediately on refresh instead of // flickering (empty → populated) while the manager connects asynchronously. function arkStateNow() { if (ark && ark.state) return ark.state; // Read persisted state whenever Ark is configured for this network — even // watch-only (seed not loaded this session): the balance/history should // show read-only, just like the on-chain balance does. Acting on it // (send/exit) still requires the seed and is gated separately. const cfg = getArkConfig(); if (!cfg) return null; return wallet.loadArkState(cfg.ark); } function arkBalance() { const s = arkStateNow(); if (!s) return null; const sum = (st) => (s.vtxos || []).filter((v) => v.state === st).reduce((n, v) => n + v.amountSat, 0); const boardingSat = (s.actions || []) .filter((a) => a.type === 'board' && a.fundingTxid && !['done', 'failed'].includes(a.step)) .reduce((n, a) => n + (a.amountSat - a.feeSat), 0); return { spendableSat: sum('spendable'), pendingSat: sum('pending'), boardingSat }; } // An ark address in the send form signals a send is coming: verify our // spendable vtxos against the server now, so a stale one (same seed active // elsewhere, restored state) is dropped before coin selection instead of // failing at cosign time. Throttled — this fires from the render path. let arkReconciledAt = 0; function maybeReconcile() { if (Date.now() - arkReconciledAt < 30_000) return; arkReconciledAt = Date.now(); connectArk().then((mgr) => mgr.reconcile()).catch(() => {}); } function arkSendReview() { if (ui.arkSent) { return h( 'div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px', onClick: () => { ui.arkSent = null; ui.send = blankSend(); render(); }, }, h('div', { class: 'check-badge' }, '✓'), h('h2', { style: 'margin:0' }, t('arkSentTitle')), h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkSent.amountSat) + ' ' + unitLabel()), h('div', { class: 'small muted' }, t('tapToProceed')) ); } const a = ui.arkSend; const row = (k, v) => h('div', { class: 'row between' }, h('span', { class: 'small muted' }, k), h('span', { class: 'small' }, v)); return h( 'div', { class: 'card col', style: 'gap:12px' }, h('h3', {}, t('arkSendTitle')), row(t('lnPayAmount'), fmtAmount(a.amountSat) + ' ' + unitLabel()), row(t('arkPayTo'), shortAddr(a.address, 14)), row(t('networkFee'), t('arkNoFee')), ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null, h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost', onClick: () => { ui.arkSend = null; ui.sendError = ''; render(); } }, t('back')), ui.busy ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' })) : h('button', { class: 'btn-primary grow', onClick: doArkSend }, t('arkSendBtn')) ) ); } async function doArkSend() { const a = ui.arkSend; ui.busy = true; ui.sendError = ''; render(); try { const mgr = await connectArk(); await mgr.send(a.address, a.amountSat); ui.arkSent = { amountSat: a.amountSat }; ui.arkSend = null; } catch (e) { ui.sendError = e.message; } 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)); } // ---- background NWC mirror --------------------------------------------- // The service worker answers wallet-connect requests while the app is // closed, spending the SAME ark balance the app does. The mirror is the // full manager state plus the chain-3 keys for its live coins and a window // of upcoming change indices (the worker cannot derive). nwc.js decides // WHEN to mirror (background answering on) and supplies the connections. async function writeBgMirror(connections, offer) { const cfg = getArkConfig(); if (!cfg || !ark || !ark.info || !ark.state) return; const prev = await loadBg(wallet._cacheKey()); const rec = buildBg({ ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey }, mgrState: ark.state, keyFor: (chain, i) => hex.encode(ark.account.deriveChild(chain).deriveChild(i).privateKey), connections, offer, }); if (prev) rec.spends = prev.spends; // the worker's log survives rewrites await saveBg(wallet._cacheKey(), rec); } // On connect, fold whatever the worker did while we were closed back into // the live state: its spent flips and change vtxos merge in (the additive // merge never resurrects anything reconcile() disproves), its key // allocations advance ours, and its in-flight ln actions become resumable. async function mergeBgWorkerState(mgr) { const rec = await loadBg(wallet._cacheKey()); if (!rec) return; if (rec.v < 3) { await sweepLegacyPouch(rec).catch((e) => console.warn('ark: pouch sweep failed', e.message)); return; } if (!rec.mgr) return; const merged = mergeArkStates(mgr.state, rec.mgr); // the worker's view of OUR vtxos is newer for anything it spent const workerState = new Map((rec.mgr.vtxos || []).map((v) => [v.id, v.state])); for (const v of merged.vtxos) { if (workerState.get(v.id) === 'spent') v.state = 'spent'; } merged.nextKeyIndex = Math.max(merged.nextKeyIndex || 1, rec.mgr.nextKeyIndex || 1); mgr.state = merged; mgr._save(); await mgr.reconcile().catch(() => {}); await mgr.resumePending().catch(() => {}); } // A leftover chain-6 pouch from before whole-balance background spending: // sweep its coins home using the keys stored in the record itself, then // let the next mirror write replace it. async function sweepLegacyPouch(rec) { if (!(rec.vtxos || []).some((v) => (v.state || 'spendable') === 'spendable')) return; const cfg = getArkConfig(); const mgr = await connectArk(); const shim = { deriveChild: () => ({ deriveChild: (i) => ({ privateKey: hex.decode(rec.keys[String(i)] || rec.keys['0']) }) }) }; let state = { v: 1, serverPubkey: rec.ark?.serverPubkey || null, mailboxCheckpoint: 0, nextKeyIndex: rec.nextKeyIndex || 20, vtxos: (rec.mgr?.vtxos || rec.vtxos || []).map((v) => ({ ...v, state: v.state || 'spendable' })), actions: [], movements: [], }; const pm = await new ArkManager({ account: shim, storage: { load: () => state, save: (s2) => { state = s2; } }, arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), }).init(); const dest = mgr.address(); for (let guard = 0; guard < 30; guard++) { const v = (pm.state.vtxos || []).find((x) => x.state === 'spendable'); if (!v) break; await pm.send(dest, v.amountSat); } await saveBg(wallet._cacheKey(), { ...rec, vtxos: [], mgr: state, sweptAt: Date.now() }); } // Pay a bolt11 with no UI at all — used by the NWC wallet service. Same // routing as the interactive path: the cheaper bridge first, the ASP's own // lightning-send as fallback. Resolves { preimage, feeSat, amountSat }. async function payInvoiceHeadless(invoice, { maxAmountSat } = {}) { const dec = maybeBolt11(invoice); if (!dec) throw new Error('not a bolt11 invoice'); if (!dec.amountSat) throw new Error('zero-amount invoices are not supported'); if (maxAmountSat && dec.amountSat > maxAmountSat) { throw new Error(`amount ${dec.amountSat} exceeds the limit of ${maxAmountSat} sat`); } const mgr = await connectArk(); // The sync merge is additive, so a vtxo spent on another device can sit // in local state looking spendable until someone checks. The interactive // send path reconciles on intent; this headless path must too, or an NWC // payment picks the stale coin and the ASP rejects it ("state: spent"). await mgr.reconcile().catch(() => {}); const id = await mgr.payLnInvoice(invoice); for (let i = 0; i < 60; i++) { const a = mgr.lnAction(id); if (!a || a.step === 'done') break; if (a.step === 'failed') throw new Error(a.error || 'payment failed'); await new Promise((r) => setTimeout(r, 2000)); await mgr.driveLn(id).catch(() => {}); } const a = mgr.lnAction(id); if (!a || a.step !== 'done') throw new Error(a?.error || a?.lastError || 'payment did not complete'); return { preimage: a.preimage, feeSat: a.feeSat, amountSat: a.amountSat }; } // 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 covers = (state, sats) => !!s && (s.vtxos || []).some((v) => v.state === state && v.amountSat >= sats); // pending funds count for the take-over decision: an in-flight payment or // revocation frees them in seconds, and the Boltz fallback can't do small // amounts at all — better to wait for ark than bounce off Boltz's minimum if (!covers('spendable', dec.amountSat || 1) && !covers('pending', dec.amountSat || 1)) return false; ui.arkLnPay = { invoice, meta: meta || null, amountSat: dec.amountSat, amount: '', feeSat: null, status: 'quote' }; ui.sendError = ''; render(); quoteArkLnPay(invoice, meta).catch((e) => { if (ui.arkLnPay && ui.arkLnPay.invoice === invoice) { ui.sendError = e.message; render(); } }); return true; } // Quote (or re-quote) an in-form lightning pay: smallest vtxo that covers // amount + its expiry-based fee. Funds locked by a settling action retry // themselves free; genuinely insufficient funds hand off to Boltz. async function quoteArkLnPay(invoice, meta) { const mgr = await connectArk(); const p = ui.arkLnPay; if (!p || p.invoice !== invoice) return; if (!p.amountSat) { p.status = 'ready'; render(); return; } 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) { const pendingCover = mgr.vtxos().some((v) => v.state === 'pending' && v.amountSat >= p.amountSat); if (pendingCover) { p.status = 'fundsPending'; render(); mgr.resumePending().catch(() => {}); // nudge whatever holds them setTimeout(() => { if (ui.arkLnPay === p) quoteArkLnPay(invoice, meta).catch(() => {}); }, 4000); return; } // can't cover once fees are in ui.arkLnPay = null; ui.sendError = t('arkLnPayFailed'); render(); return; } p.feeSat = fee; p.status = 'ready'; render(); } 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.status === 'fundsPending' ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkLnFundsPending'))) : 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 !== 'ready', 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', '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. 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, m.status !== 'complete' ? h('span', { class: 'tag pending' }, m.status) : null), h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))), h('div', { style: 'text-align:right' }, h('div', { class: incoming ? 'amount-pos' : 'amount-neg' }, (incoming ? '+' : '-') + fmtAmount(m.amountSat))) ); } function arkMoveDetailView(m) { 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)); const url = m.txid ? wallet.api.explorerTx(m.txid) : null; return h( 'div', { class: 'card col', style: 'gap:10px' }, h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { html: ARK_ICON(18) }), h('h3', { style: 'margin:0' }, label), m.status !== 'complete' ? h('span', { class: 'tag pending' }, m.status) : null), h('div', { class: incoming ? 'amount-pos' : 'amount-neg', style: 'font-size:20px' }, (incoming ? '+' : '-') + fmtAmount(m.amountSat) + ' ' + unitLabel()), row(t('dateLabel'), new Date(m.ts).toLocaleString()), m.to ? row(t('arkPayTo'), shortAddr(m.to, 16, 12)) : null, m.vtxoId ? row(t('arkVtxoId'), shortTxid(m.vtxoId)) : null, m.detail ? row(t('detailsLabel'), m.detail) : null, m.txid ? h('div', { class: 'col', style: 'gap:6px' }, h('div', { class: 'small muted' }, t('transactionId')), h('div', { class: 'addr-box', style: 'width:100%' }, m.txid), h('div', { class: 'row gap6' }, copyBtn(m.txid, t('copyTxid')), h('a', { class: 'btn btn-sm', href: url, target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(url); } }, t('viewOnMempool')))) : null, m.to ? copyBtn(m.to, t('copyAddress')) : null, h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkMoveDetail = null; render(); } }, t('back')) ); } function arkCard() { const net = getNetwork(); const id = getArkProviderId(net); const custom = getArkCustom(net); const presets = arkPresets(net); const applyProvider = (v) => { setArkProviderId(v, net); ui.receiveType = null; initArk(); render(); }; const head = [ h('h3', { class: 'row gap6', style: 'align-items:center' }, h('span', { html: ARK_MARK(18) }), 'Ark'), h('p', { class: 'small muted', style: 'margin:0' }, t('arkDesc')), h('select', { onChange: (e) => applyProvider(e.target.value) }, presets.map((p) => h('option', { value: p.id, selected: p.id === id }, p.label))), id === 'custom' ? h('div', { class: 'col', style: 'gap:8px' }, h('label', { class: 'field' }, h('span', { class: 'lab' }, t('arkServerUrl')), h('input', { type: 'text', class: 'mono-input', placeholder: 'https://ark.example.com', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: custom.ark, onChange: (e) => { setArkCustom({ ark: e.target.value.trim(), esplora: custom.esplora }, net); initArk(); render(); }, })), h('label', { class: 'field' }, h('span', { class: 'lab' }, t('arkEsploraUrl')), h('input', { type: 'text', class: 'mono-input', placeholder: 'https://mempool.example.com/api', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: custom.esplora, onChange: (e) => { setArkCustom({ ark: custom.ark, esplora: e.target.value.trim() }, net); initArk(); render(); }, }))) : null, ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null, ]; if (id === 'off' || wallet.watchOnly) { return h('div', { class: 'card col' }, ...head, wallet.watchOnly && id !== 'off' ? h('div', { class: 'small faint' }, t('arkWatchOnly')) : null); } if (!ark || !ark.state) { return h('div', { class: 'card col' }, ...head, arkConnectPromise ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkConnecting'))) : h('button', { class: 'btn-ghost btn-block', onClick: () => connectArk().catch(() => {}) }, t('arkConnectBtn'))); } const spendables = ark.vtxos().filter((v) => v.state === 'spendable'); const pendingActions = ark.pendingActions(); const row = (k, v) => h('div', { class: 'row between' }, h('span', { class: 'small muted' }, k), h('span', { class: 'small' }, v)); return h( 'div', { class: 'card col', style: 'gap:10px' }, ...head, spendables.length ? row(t('arkVtxos'), String(spendables.length)) : null, // in-flight operations, summarized in plain words (exits get their own // per-exit status lines below) (() => { 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', '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; })(), spendables.length >= 1 ? h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy, onClick: doArkRefresh }, ui.arkBusy === 'refresh' ? h('span', { class: 'spinner sm' }) : t('arkRefreshBtn', { n: spendables.length })) : null, // Moving funds out (cooperative offboard / unilateral exit) lives on its // own page, reached from the "Exit" link on the Ark balance line. spendables.length >= 1 ? h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkExitPage = true; ui.arkError = ''; render(); } }, t('arkExitPageTitle')) : null ); } async function doArkBoard() { const sats = parseInt((ui.arkBoardAmt || '').trim(), 10); if (!sats) return; ui.arkBusy = 'board'; ui.arkError = ''; render(); try { const { actionId, fundingAddress, feeSat } = await ark.startBoard(sats); const feeRate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; const draft = wallet.buildTx({ recipients: [{ address: fundingAddress, amount: sats }], feeRate, noSort: true }); const hexTx = wallet.sign(draft.tx); const txid = await wallet.broadcast(hexTx); // Like the main send flow: reflect the spend locally and let the // poll/watcher reconcile. A scan here would race the explorer's indexing // and could resurrect the just-spent coin. wallet.applySentTx(draft.tx); await ark.completeBoard(actionId, txid); ui.arkBoardAmt = ''; ui.arkBoarded = { txid, netSat: sats - feeSat }; } catch (e) { ui.arkError = e.message; } ui.arkBusy = null; render(); } // ---- ark zaps (draft protocol, NIP-61-shaped) ---------------------------- // Lightning zaps (NIP-57) are built around LNURL + bolt11; ark needs // neither. Mirroring nutzaps (NIP-61): the recipient announces their ark // address as a replaceable event, the sender pays with a plain instant // arkoor send and publishes a receipt referencing the delivered vtxo. // Kinds are provisional until a NIP lands. const ARK_INFO_KIND = 10037; // replaceable: ["ark",
], ["network",