// ArkManager — persistent, crash-safe Ark wallet state on top of the // src/ark protocol modules. Follows the wallet's SwapManager shape: a class over a // wallet-scoped storage adapter, driving multi-step actions that are // checkpointed to storage before and after every server-effectful RPC. // // The invariant that matters (learned the hard way on regtest): the ASP marks // an input spent the moment it cosigns, so every action persists enough state // BEFORE the effectful call to resume — and signed vtxo bytes are persisted // the instant they exist. // // Keys: vtxo keys on account chain 3 (index 0 = the receive address key, // 1.. = change/refresh outputs), mailbox key on chain 4. Chains 0-2 are used // by the wallet for receive/change/swaps. import { hex } from '@scure/base'; import { sha256 } from '@noble/hashes/sha256'; import { secp256k1 } from '@noble/curves/secp256k1'; import * as musig2 from '@scure/btc-signer/musig2'; import { getArkInfo, handshake, encodeAddress, decodeAddress, blindMailboxId, readMailbox, decodeVtxo, arkIdFromServerPubkey, GrpcError, grpcStream, decodeMailboxMessage, mailboxRequestBytes, getVtxoStatus, VTXO_STATE_SPENT, vtxoBytesToStr, vtxoBytesFromStr, vtxoBytesNormalize } from './proto.js'; import { buildArkoorSend, cosignWithServer, cosignPackageWithServer, buildAllSignedVtxos, registerVtxoTransactions, postArkoorMessage, txid, genUserNonces, cosignPartBytes, combineCosign, } from './send.js'; import { decodeBolt11, decodeLnInvoice, fetchBolt12Invoice, lnSendFee, lnReceiveFee, fetchLnRouteFee, requestLightningPayHtlcCosign, initiateLightningPayment, checkLightningPayment, requestLightningPayHtlcRevocation, startLightningReceive, checkLightningReceive, prepareLightningReceiveClaim, claimLightningReceive, cancelLightningReceive, lightningReceiveAttestation, } from './lightning.js'; import { boardFee, p2trAddress, buildBoard, requestBoardCosign, combineBoardSignature, encodeBoardVtxo, registerBoardVtxo, } from './board.js'; import { submitRoundParticipation, roundParticipationStatus, parseTx, cosignHarkLeaf, requestForfeitNonces, forfeitBundle, forfeitVtxos, encodeVtxoFromDecoded, } from './refresh.js'; import { getOffboardFeeRate, feeRateKwu, offboardFeeParts, offboardAttestation, prepareOffboard, validateOffboardTx, signOffboardForfeits, finishOffboard, P2TR_DUST, } from './offboard.js'; import { signedExitTxs } from './exit.js'; import { validateVtxo, VtxoValidationError } from './validate.js'; import { decodeOffer, decodeBolt12Invoice, verifyOfferInvoice, encodeBech32Raw } from '../bolt12.js'; const EMPTY_STATE = () => ({ v: 1, serverPubkey: null, // which ASP this state belongs to (guarded in init) mailboxCheckpoint: 0, nextKeyIndex: 1, // 0 is the receive-address key vtxos: [], // { id, bytes, keyIndex, amountSat, expiryHeight, state } actions: [], // { id, type, step, ... } movements: [], // { id, type, amountSat, ts, status, detail } scheduled: [], // standing-order refreshes: { unlockHash, inputIds, scheduledHeight, ... } }); // Local state must not grow forever. Two things did: a spent coin kept its // signed bytes (useful only for an exit, which a spent coin can never take) // and a done action kept every output vtxo it minted as hex (the change / // HTLC coins live in `vtxos` in their own right). Both go once they are // safely history — a spend older than SPENT_BYTES_AFTER with no in-flight // action naming it, an action done longer than DONE_HEAVY_AFTER ago. Ids, // amounts and inputIds stay: history rows and spend dedupe still need them. // Mutates in place; idempotent; cheap (no decoding). const SPENT_BYTES_AFTER = 30 * 86400_000; const DONE_HEAVY_AFTER = 7 * 86400_000; const ACTION_HEAVY = ['destBytes', 'changeBytes', 'destBytesList', 'changeBytesList', 'htlcBytesList', 'vtxoBytes', 'txHex', 'fundingTxHex', 'outputVtxos']; const actionTs = (a) => Number(String(a.id || '').split('-').pop()) || 0; export function pruneArkState(state, now = Date.now()) { if (!state) return state; const actions = state.actions || []; const inFlight = new Set(); for (const a of actions) { if (a.step === 'done' || a.step === 'failed') continue; for (const id of a.inputIds || []) inFlight.add(id); for (const p of a.parts || []) if (p.inputId) inFlight.add(p.inputId); if (a.inputId) inFlight.add(a.inputId); } // when was a coin last named by anything? (a spend leaves a movement or a // done action naming it; an unnamed spent coin has no date and is kept) const lastNamed = new Map(); const name = (id, ts) => { if (id && ts) lastNamed.set(id, Math.max(lastNamed.get(id) || 0, ts)); }; for (const m of state.movements || []) { name(m.vtxoId, m.ts); for (const id of m.inputIds || []) name(id, m.ts); } for (const a of actions) { const ts = actionTs(a); for (const id of a.inputIds || []) name(id, ts); for (const p of a.parts || []) name(p.inputId, ts); name(a.inputId, ts); for (const id of a.htlcVtxoIds || []) name(id, ts); } for (const v of state.vtxos || []) { if (!v.bytes) continue; if (v.state === 'spent' && !inFlight.has(v.id)) { const ts = lastNamed.get(v.id); if (ts && now - ts > SPENT_BYTES_AFTER) { delete v.bytes; continue; } } v.bytes = vtxoBytesNormalize(v.bytes); // hex from older builds → base64 } for (const a of actions) { if (a.step !== 'done') continue; const ts = actionTs(a); if (!ts || now - ts < DONE_HEAVY_AFTER) continue; for (const k of ACTION_HEAVY) if (k in a) delete a[k]; } return state; } export class ArkManager { constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint }) { this.account = account; // HDKey node; ark keys derived beneath it this.storage = storage; // { load(): obj|null, save(obj): void } this.arkUrl = arkUrl; this.esploraUrl = esploraUrl; // Optional second witness for tx confirmations (the wallet's own chain // view) — consulted when the esplora above can't answer. this.statusHint = statusHint || null; this.network = network; // where to ask what a lightning payment's route will cost (names /lnquote) this.lnQuoteUrl = lnQuoteUrl !== undefined ? lnQuoteUrl : network === 'mainnet' ? 'https://names.halwallet.app/lnquote' : network === 'mutinynet' ? 'https://names.halwallet.app/lnquote?network=mutinynet' : null; 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 this._refreshDriving = new Set(); // same, for round claims: sync loop vs mailbox breadcrumb this._schedPollAt = new Map(); // last status poll per standing-order unlock hash // A derived key for an index never changes for this wallet, but computing // it costs a BIP32 derive + a secp256k1 getPublicKey (a full point mul). // reconcile() alone calls _keyForVtxo for every spendable coin, and decode/ // send/exit/fee-math hit it again — dozens of EC muls per boot burst, the // dominant main-thread cost in a CPU profile. Memoize by index. this._keyCache = new Map(); } // ---- keys ---- _key(index) { let k = this._keyCache.get(index); if (k) return k; const node = this.account.deriveChild(3).deriveChild(index); k = { privkey: node.privateKey, pubkey: secp256k1.getPublicKey(node.privateKey, true) }; this._keyCache.set(index, k); return k; } _mailboxKey() { if (this._mbKey) return this._mbKey; const node = this.account.deriveChild(4).deriveChild(0); return (this._mbKey = { privkey: node.privateKey, pubkey: secp256k1.getPublicKey(node.privateKey, true) }); } _keyForVtxo(v) { return this._key(v.keyIndex); } // ---- lifecycle ---- async init() { const loaded = this.storage.load(); this.state = loaded || EMPTY_STATE(); // A wallet opening on a device for the first time (a fresh import, a new // browser) is about to discover its whole history at once, and every // movement it records gets stamped with the moment it was found — so // without this, a week-old payment looks like it just arrived. Mark the // first catch-up as history: only what turns up afterwards is news. if (!loaded) this.state.baselinePending = true; // drop duplicate receive movements (same vtxo) left by the former // poll/stream race; the vtxo set itself was always deduped const seen = new Set(); this.state.movements = this.state.movements.filter((m) => { if (m.type !== 'receive' || !m.vtxoId) return true; if (seen.has(m.vtxoId)) return false; seen.add(m.vtxoId); return true; }); this.info = await getArkInfo(this.arkUrl); const hs = await handshake(this.arkUrl).catch(() => null); this.psa = hs?.psa; this.serverPub = hex.decode(this.info.serverPubkey); // Pre-namespacing state can now be proven to belong to this server (the // vtxo wire format names its cosigner), so claim it before deciding. if (!this.state.serverPubkey && this.storage.adopt) { const adopted = this.storage.adopt(this.info.serverPubkey); if (adopted) this.state = adopted; } // A vtxo is only meaningful against the server that cosigned it. State // carried over from a different ASP (a provider switch, or a snapshot // adopted from the pre-namespacing key) would show phantom balance and // try to resume actions this server has never heard of — so drop it. // Nothing is lost: the other ASP's state lives under its own key. // Decisive check: whatever any stamp claims, a vtxo names its own // cosigner. If these coins were signed by a different server they are not // spendable here and must not be shown. const coinOwner = (() => { const v = (this.state.vtxos || []).find((x) => x.bytes); try { return v ? decodeVtxo(vtxoBytesFromStr(v.bytes)).serverPubkey : null; } catch { return null; } })(); if ((this.state.serverPubkey && this.state.serverPubkey !== this.info.serverPubkey) || (coinOwner && coinOwner !== this.info.serverPubkey)) { this.state = EMPTY_STATE(); } if (!this.state.serverPubkey) { this.state.serverPubkey = this.info.serverPubkey; this._save(); } // Older builds kept server-rejected dust in the spendable balance. let expiryChanged = false; for (const action of this.state.actions) expiryChanged = this._recordExpiryRejection(action) || expiryChanged; if (expiryChanged) this._save(); return this; } _save() { // a monotonic revision that bumps on every state change — lets memoized // derivations (history build, etc.) invalidate exactly when the data // changes instead of on a timer that re-cools between interactions this.state._rev = (this.state._rev || 0) + 1; pruneArkState(this.state); this.storage.save(this.state); this.onUpdate(this); } _movement(m) { this.state.movements.push({ id: `${Date.now()}-${this.state.movements.length}`, ts: Date.now(), ...m }); } // When did a mailbox vtxo come into being? Its expiry counts down from the // server's vtxo lifetime, so (lifetime - blocksLeft) blocks have passed // since the sender created it. At ~10 minutes a block that dates a backlog // claimed at first sign-in as the old payments they are — instead of a wall // of "seconds ago" receives and a bogus celebration. _estimateReceiveTs(v, tip) { const life = (this.info && this.info.vtxoExpiryDelta) || 0; if (!life || !tip || !v.expiryHeight) return Date.now(); const age = life - (v.expiryHeight - tip); if (age <= 0) return Date.now(); return Date.now() - age * 600_000; } async _tipMemo() { if (this._tipAt && Date.now() - this._tipAt < 60_000) return this._tipH; try { this._tipH = await this.chain.tipHeight(); this._tipAt = Date.now(); } catch { /* keep whatever we had */ } return this._tipH || 0; } _vtxo(id) { return this.state.vtxos.find((v) => v.id === id); } _addVtxo(decoded, bytes, keyIndex, state = 'spendable') { if (this._vtxo(decoded.id)) return false; this.state.vtxos.push({ id: decoded.id, bytes: vtxoBytesToStr(bytes), keyIndex, amountSat: decoded.amountSat, expiryHeight: decoded.expiryHeight, state, }); return true; } _decoded(v) { // vtxo bytes are immutable per id — parse once. The depth advisory and // fee estimates decode every coin on every render without this. const hit = this._decodeCache && this._decodeCache.get(v.id); if (hit) return hit; const d = decodeVtxo(vtxoBytesFromStr(v.bytes)); (this._decodeCache ||= new Map()).set(v.id, d); if (this._decodeCache.size > 200) this._decodeCache.delete(this._decodeCache.keys().next().value); return d; } // ---- chain adapter (esplora REST, same API the wallet already speaks) ---- // // With FAILOVER on mainnet: some networks simply can't reach mempool.space // (one tester's laptop failed every chain call for days — stranded mailbox // receives, quotes that never arrived), and a single host made every one // of those a dead end. A request tries the last host that worked, then the // rest; rate limits and 5xx rotate too. Non-mainnet keeps its lone host — // there is no public twin of a regtest or mutinynet esplora. async _esplora(path, init) { const hosts = (this._esploraHosts ||= [ this.esploraUrl, // last resort: our own registrar relays chain calls — the one host a // wallet that can reach coinos at all provably reaches ...(this.network === 'mainnet' ? ['https://blockstream.info/api', 'https://mempool.space/api', 'https://names.coinos.io/esplora'] : []), ].filter((h, i, a) => h && a.indexOf(h) === i)); const start = this._esploraIdx || 0; let lastErr; for (let i = 0; i < hosts.length; i++) { const idx = (start + i) % hosts.length; try { const r = await fetch(hosts[idx] + path, init); if (r.status === 429 || r.status >= 500) { lastErr = new Error(`chain status ${r.status}`); continue; } this._esploraIdx = idx; return r; } catch (e) { lastErr = e; } } throw lastErr; } get chain() { return { // no-store: a CDN-cached tip lags a 30s-block network by many blocks, // and a stale tip under-shoots HTLC expiries the server then rejects tipHeight: async () => Number(await this._esplora('/blocks/tip/height', { cache: 'no-store' }).then((r) => r.text())), getTxStatus: async (txid) => { const r = await this._esplora(`/tx/${txid}/status`); if (r.ok) return r.json(); if (r.status === 404) return null; // unknown tx is an answer throw new Error(`chain status ${r.status}`); // odd refusals must SURFACE }, getTxHex: async (txid) => this._esplora(`/tx/${txid}/hex`).then((r) => r.ok ? r.text() : null), broadcastTx: async (txHex) => { const r = await this._esplora('/tx', { method: 'POST', body: txHex }); const body = await r.text(); // a rebroadcast after a crash is fine — the tx being known already IS success if (!r.ok && !/already/i.test(body)) throw new Error(`broadcast failed: ${body.slice(0, 120)}`); return body.trim(); }, }; } // ---- public surface ---- address() { const k0 = this._key(0); const blinded = blindMailboxId(this._mailboxKey().pubkey, hex.decode(this.info.mailboxPubkey), k0.privkey); return encodeAddress({ testnet: this.info.network !== 'bitcoin', serverPubkey: this.serverPub, userPubkey: k0.pubkey, blindedMailboxId: blinded, }); } // A coin past its expiry is money only the server still honors: the // recipient's validator refuses anything expired at pickup time, so sending // one strands the amount in a vtxo no wallet will accept. Sub-dust coins are // the exception — they have no unilateral exit to protect and the coinos ASP // keeps honoring them off-chain (mirrors validateVtxo's carve-out). _expired(v, tip) { return !!v.expiryRejected || (!!tip && !!v.expiryHeight && v.amountSat >= 330 && v.expiryHeight <= tip); } // The dust exemption is not a promise that every old coin will be // accepted. A cosign rejection names the exact expired input; remember // that verdict without discarding the other inputs in its atomic package. _recordExpiryRejection(action) { if (action.step !== 'failed') return false; const m = /vtxo ([0-9a-f]{64}:\d+) expired at height (\d+) \(tip = (\d+)\)/i.exec(action.error || ''); if (!m) return false; const inputs = [...(action.inputIds || []), ...this._sendParts(action).map(p => p.inputId)]; const v = inputs.includes(m[1]) && this._vtxo(m[1]); if (!v || v.expiryHeight !== Number(m[2]) || v.expiryRejected) return false; v.expiryRejected = true; this._tipH = Math.max(this._tipH || 0, Number(m[3])); return true; } balance() { const tip = this._tipH || 0; const sum = (st) => this.state.vtxos .filter((v) => v.state === st && !this._expired(v, tip)).reduce((n, v) => n + v.amountSat, 0); const expiredSat = this.state.vtxos .filter((v) => v.state === 'spendable' && this._expired(v, tip)).reduce((n, v) => n + v.amountSat, 0); // Boards whose funding tx is broadcast but whose vtxo isn't registered yet: // the sats have left the on-chain balance, so surface them here instead of // letting them vanish until the board completes. const boardingSat = this.state.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, expiredSat }; } // Receives newer than the last acknowledgement — the UI's "Payment // received!" celebration. Recency-guarded like the on-chain equivalent so an // old payment never celebrates when a wallet is opened much later. unseenReceives() { const ack = this.state.receiveAckTs || 0; const cutoff = Date.now() - 2 * 3600 * 1000; return this.state.movements.filter((m) => ['receive', 'ln-receive'].includes(m.type) && m.status === 'complete' && m.ts > ack && m.ts > cutoff); } ackReceives() { this.state.receiveAckTs = Date.now(); this._save(); } vtxos() { return this.state.vtxos.slice(); } movements() { return this.state.movements.slice(); } pendingActions() { return this.state.actions.filter((a) => !['done', 'failed'].includes(a.step)); } // Read new mailbox messages, fully validate incoming vtxos, then push any // in-flight actions forward. async sync() { const baselining = !!this.state.baselinePending; // keep the cached tip fresh: balance() and input selection judge coin // expiry against it synchronously, and a stale tip hides dead coins await this._tipMemo(); const mailbox = this._mailboxKey(); const { messages } = await readMailbox(this.arkUrl, mailbox, this.state.mailboxCheckpoint); let changed = false; for (const m of messages) { // A transiently-failing message stops the batch: processing the ones // behind it would advance the cursor past it (see the hold logic in // _processMailboxMessage) — the next poll re-reads from where it // stands, so nothing behind the failure is lost either. try { changed = (await this._processMailboxMessage(m)) || changed; } catch (e) { console.warn('ark: mailbox message deferred —', e.message); break; } } // Everything the first catch-up found is history, not news. if (baselining) { this.state.receiveAckTs = Date.now(); delete this.state.baselinePending; changed = true; } if (changed) this._save(); await this.resumePending(); // Standing-order refreshes live beside the actions, not among them. await this._driveScheduled().catch((e) => console.warn('ark: scheduled refresh check failed —', e.message)); // The display is only as honest as the last reconcile: state restored // from a snapshot (or mirrored by the NWC worker) can show coins the // server already saw spent — a balance you can look at but not spend. // Sweep that drift on a slow cadence, not just at pay time. if (Date.now() - (this._reconciledAt || 0) > 10 * 60_000) { this._reconciledAt = Date.now(); await this.reconcile().catch(() => {}); } } // Handle one mailbox message (from a poll or the live stream). Returns // whether state changed; the caller saves. Duplicate-safe: vtxos dedupe by // id and the checkpoint only moves forward. // // The cursor moves only AFTER the message's work is done — a final // rejection counts as done, a transient failure does not. Advancing first // meant one esplora hiccup mid-validation skipped a coin forever: the // throw aborted adoption, the cursor had already moved in memory, any // later save persisted it, and every future read started past the // message (monogram's 204-sat receive sat invisible for two days). And a // stuck message is never leapt: later messages may process fine, but the // cursor holds at the lowest failed checkpoint until it clears — else a // healthy message right behind the sick one would skip it just the same. async _processMailboxMessage(m) { try { const changed = await this._handleMailboxMessage(m); if (this._mailboxStuckAt === m.checkpoint) this._mailboxStuckAt = null; if (this._mailboxStuckAt != null && m.checkpoint > this._mailboxStuckAt) return changed; if (m.checkpoint > this.state.mailboxCheckpoint) { this.state.mailboxCheckpoint = m.checkpoint; return true; } return changed; } catch (e) { if (this._mailboxStuckAt == null || m.checkpoint < this._mailboxStuckAt) this._mailboxStuckAt = m.checkpoint; throw e; } } async _handleMailboxMessage(m) { let changed = false; 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 === 'roundCompleted') { // A round ran for one of our participations. Normally the action that // submitted it drives the claim — but if that action is gone (crash // mid-claim, snapshot rollback, a foreign device that never came // back), this breadcrumb is the only trace, and money sits 'unclaimed' // on the server until someone acts on it (cooney's 28,833 sats did, // for 8 days). Adopt it; rescueParticipation no-ops on inputs we don't // hold and the server side is idempotent. // A standing order's round announces itself the same way, and this is // the earliest word of it — claim on the breadcrumb instead of waiting // for the next scheduled-height check. const standing = (this.state.scheduled || []).find((s) => s.unlockHash === m.unlockHash); if (standing) { this._promoteScheduled(standing).catch((e) => console.warn('ark: scheduled refresh not claimed —', e.message)); } else if (m.unlockHash && !this.state.actions.some((a) => a.unlockHash === m.unlockHash && a.step !== 'failed')) { this.rescueParticipation(m.unlockHash).catch((e) => console.warn('ark: stranded participation not adopted —', e.message)); } return changed; } if (m.kind !== 'arkoor') return changed; const ourKeys = [hex.encode(this._key(0).pubkey)]; const tip = await this._tipMemo(); // The mailbox checkpoint IS the delivery timestamp (ms << 20, verified // to the millisecond against the server's rows) — the one honest date // for a receive, whether it lands live or surfaces in an import's // catch-up. Expiry-based estimation stays only as the fallback for // messages without a usable checkpoint. const live = !this.state.baselinePending; const ckTs = (() => { const t = Math.floor(Number(m.checkpoint || 0) / 1048576); return t > 1.6e12 && t < Date.now() + 60_000 ? t : null; })(); for (const v of m.vtxos) { if (this._vtxo(v.id)) { // The vtxo beat the message here (a mirror or snapshot merge carried // it) — the money is present but the story is missing. Backfill the // receive movement so history matches the balance. if (!this.state.movements.some((x) => x.vtxoId === v.id)) { this._movement({ type: 'receive', amountSat: v.amountSat, status: 'complete', vtxoId: v.id, ts: ckTs ?? (live ? Date.now() : this._estimateReceiveTs(this._decoded(this._vtxo(v.id)), tip)) }); changed = true; } continue; } try { await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: ourKeys }); } catch (e) { if (e instanceof VtxoValidationError) { this._movement({ type: 'receive', amountSat: v.amountSat, status: 'rejected', detail: e.message }); changed = true; continue; } throw e; // network errors etc: retry next sync } // _addVtxo dedupes by id — the poll and the live stream can process the // same message concurrently (validation awaits in between), and only the // copy that actually added the vtxo may record the receive movement. if (!this._addVtxo(v, v._raw.bytes, 0)) continue; this._movement({ type: 'receive', amountSat: v.amountSat, status: 'complete', vtxoId: v.id, ts: ckTs ?? (live ? Date.now() : this._estimateReceiveTs(v, tip)), }); changed = true; } return changed; } // Live mailbox push via the SubscribeMailbox gRPC stream — receives land the // moment the sender delivers, instead of on the next poll. Reconnects with a // short backoff until stopMailboxStream(). startMailboxStream() { if (this._streaming) return; this._streaming = true; const loop = async () => { while (this._streaming) { try { this._streamAbort = new AbortController(); await grpcStream( this.arkUrl, 'mailbox_server.MailboxService/SubscribeMailbox', mailboxRequestBytes(this._mailboxKey(), this.state.mailboxCheckpoint), { signal: this._streamAbort.signal, onMessage: (bytes) => { this._processMailboxMessage(decodeMailboxMessage(bytes)) .then((changed) => { if (changed) this._save(); }) .catch(() => {}); // validation retried by the next poll }, }, ); } catch {} if (this._streaming) await new Promise((r) => setTimeout(r, 3000)); } }; loop(); } stopMailboxStream() { this._streaming = false; try { this._streamAbort?.abort(); } catch {} this._streamAbort = null; } // Check every locally-spendable vtxo against the server's authoritative // off-chain ledger and flip the ones it reports spent (state drift from the // same seed active elsewhere, or a restored state snapshot). Trust flows one // way — spendable -> spent — so a lying server can hide balance from the UI // but never mint it, and the signed bytes stay held for unilateral exit. async reconcile() { const candidates = this.state.vtxos.filter((v) => v.state === 'spendable'); // Each status query signs a challenge (schnorr) and may decode the vtxo — // real secp256k1 work. Building them all in one synchronous map was a // ~100ms+ EC burst on boot (dozens of coins), the stall behind a carousel // drag right after a refresh. Kick the requests off in time-sliced batches // so the signing spreads across frames; the network calls still overlap. const pending = []; let t0 = performance.now(); for (const v of candidates) { pending.push(getVtxoStatus(this.arkUrl, this._decoded(v).point.raw, this._keyForVtxo(v).privkey).catch(() => null)); if (performance.now() - t0 > 6) { await new Promise((r) => setTimeout(r)); t0 = performance.now(); } } const states = await Promise.all(pending); // unreachable/erroring server changes nothing let changed = false; candidates.forEach((v, i) => { if (states[i] !== VTXO_STATE_SPENT) return; v.state = 'spent'; this._movement({ type: 'reconcile', amountSat: v.amountSat, status: 'complete', vtxoId: v.id, detail: 'spent elsewhere (server vtxo status)' }); changed = true; }); if (changed) this._save(); return changed; } async resumePending() { for (const action of this.pendingActions()) { try { if (action.type === 'send') await this._driveSend(action); 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. // Recorded and swallowed for EVERY type: one broken action rethrowing // here would starve everything queued behind it, forever and silently. action.lastError = e.message; this._save(); } } } // ---- unilateral exit ---- // Marks the vtxo pending and records the action. The ark FEATURE drives the // steps (it owns the on-chain wallet needed for fee-bump coins and the // claim destination); crash-safety comes from the persisted action like // every other flow. signedExitTxs() up front validates the chain is fully // signed before anything is committed. startExit(vtxoId) { const v = this._vtxo(vtxoId); if (!v || v.state !== 'spendable') throw new Error('vtxo is not spendable'); const txs = signedExitTxs(this._decoded(v), this.serverPub); const action = { id: `exit-${Date.now()}-${vtxoId.slice(0, 8)}`, type: 'exit', step: 'chain', vtxoId, amountSat: v.amountSat, txids: txs.map((t) => t.txid), }; v.state = 'pending'; this.state.actions.push(action); this._save(); return action; } // ---- offboard (collaborative exit: spendable vtxos -> one on-chain output) ---- // Whole vtxos only — there is no change on this path. Default is everything // spendable; vtxoIds selects a subset (the feature splits an exact-amount // vtxo first via a self-send when the user asks for a partial move). async startOffboard(spk, address, vtxoIds) { if (this.pendingActions().some((a) => a.type === 'offboard')) { throw new Error('an offboard is already in progress'); } const inputs = vtxoIds ? vtxoIds.map((id) => this._vtxo(id)) : this.state.vtxos.filter((v) => v.state === 'spendable'); if (!inputs.length) throw new Error('no spendable ark balance'); if (inputs.some((v) => !v || v.state !== 'spendable')) throw new Error('vtxo not spendable'); if (this.info.maxOffboardInputs && inputs.length > this.info.maxOffboardInputs) { throw new Error(`too many coins for one offboard (${inputs.length}) — refresh/consolidate first`); } const tip = await this.chain.tipHeight(); const satVkb = await getOffboardFeeRate(this.arkUrl); const grossSat = inputs.reduce((n, v) => n + v.amountSat, 0); const feeParts = offboardFeeParts({ spkLen: spk.length, satVkb, fees: this.info.offboardFees, tip, inputs: inputs.map((v) => ({ amountSat: v.amountSat, expiryHeight: v.expiryHeight })), }); const feeSat = feeParts.totalSat; const netSat = grossSat - feeSat; if (netSat < P2TR_DUST) throw new Error('ark balance too small to offboard after fees'); const action = { id: `offboard-${Date.now()}`, type: 'offboard', step: 'created', inputIds: inputs.map((v) => v.id), address, spkHex: hex.encode(spk), grossSat, feeSat, chainFeeSat: feeParts.chainSat, serviceFeeSat: feeParts.serviceSat, netSat, rateKwu: feeRateKwu(satVkb), }; for (const v of inputs) v.state = 'pending'; this.state.actions.push(action); this._save(); await this._driveOffboard(action); return action; } async _driveOffboard(action) { const inputRecs = action.inputIds.map((id) => this._vtxo(id)); if (action.step === 'created') { const decoded = inputRecs.map((v) => this._decoded(v)); const keys = inputRecs.map((v) => this._keyForVtxo(v)); const spk = hex.decode(action.spkHex); const inputIdRaws = decoded.map((d) => d.point.raw); await registerVtxoTransactions(this.arkUrl, inputRecs.map((v) => vtxoBytesFromStr(v.bytes))); const attestations = keys.map((k) => offboardAttestation({ netSat: action.netSat, spk, inputIdRaws }, k.privkey)); const { txBytes, serverNonces } = await prepareOffboard(this.arkUrl, { netSat: action.netSat, spk, rateKwu: action.rateKwu, inputIdRaws, attestations, }); const parsed = parseTx(txBytes); validateOffboardTx(parsed, { netSat: action.netSat, spk, nInputs: decoded.length }); const { pubNonces, partials, offboardTxidInternal } = signOffboardForfeits({ inputs: decoded, keys, serverPub: this.serverPub, parsed, serverNonces, }); // the server holds our forfeits after FinishOffboard: persist the txid // first so a crash mid-call can still find the tx on chain action.txid = parsed.txid; action.step = 'finishing'; this._save(); const signed = await finishOffboard(this.arkUrl, { offboardTxidInternal, pubNonces, partials }); if (parseTx(signed).txid !== action.txid) throw new Error('server returned a different offboard tx'); action.txHex = hex.encode(signed); for (const v of inputRecs) v.state = 'spent'; action.step = 'signed'; this._save(); } if (action.step === 'finishing') { // crashed between FinishOffboard and persisting its result. The server // broadcasts the offboard tx itself after a successful finish, so ask it // whether the inputs were forfeited: spent means the finish landed (wait // for the server's broadcast to confirm); still-spendable means it never // did — start the action over. const probe = await getVtxoStatus(this.arkUrl, this._decoded(inputRecs[0]).point.raw, this._keyForVtxo(inputRecs[0]).privkey); if (probe === VTXO_STATE_SPENT) { for (const v of inputRecs) v.state = 'spent'; action.step = 'broadcast'; this._movement({ type: 'offboard', amountSat: action.netSat, status: 'complete', txid: action.txid, detail: `fee ${action.feeSat} sat`, to: action.address, feeSat: action.feeSat, chainFeeSat: action.chainFeeSat, serviceFeeSat: action.serviceFeeSat, inputIds: action.inputIds, }); this._save(); } else { action.step = 'created'; this._save(); return; // retried on the next sync } } if (action.step === 'signed') { await this.chain.broadcastTx(action.txHex); action.step = 'broadcast'; this._movement({ type: 'offboard', amountSat: action.netSat, status: 'complete', txid: action.txid, detail: `fee ${action.feeSat} sat`, to: action.address, feeSat: action.feeSat, chainFeeSat: action.chainFeeSat, serviceFeeSat: action.serviceFeeSat, inputIds: action.inputIds, }); this._save(); } if (action.step === 'broadcast') { const st = await this.chain.getTxStatus(action.txid); if (!st?.confirmed) return; // retried on sync action.step = 'done'; this._save(); } } // ---- send ---- _selectInput(amountSat) { const candidates = this.state.vtxos .filter((v) => v.state === 'spendable' && v.amountSat >= amountSat) .sort((a, b) => a.amountSat - b.amountSat); if (!candidates.length) { const total = this.balance().spendableSat; throw new Error(total >= amountSat ? 'no single vtxo covers this amount — consolidate with refresh() first' : 'insufficient ark balance'); } return candidates[0]; } // Cover `amountSat` with as few coins as possible: the smallest single vtxo // that covers it, else largest-first until the sum does. A balance scattered // by many small receives stays spendable — the package cosign spends all // the inputs atomically and the recipient just receives several vtxos. _selectInputs(amountSat, tip = this._tipH || 0) { const spendable = this.state.vtxos .filter((v) => v.state === 'spendable' && !this._expired(v, tip)); const single = spendable .filter((v) => v.amountSat >= amountSat) .sort((a, b) => a.amountSat - b.amountSat)[0]; if (single) return [single]; const picked = []; let sum = 0; for (const v of [...spendable].sort((a, b) => b.amountSat - a.amountSat)) { picked.push(v); sum += v.amountSat; if (sum >= amountSat) break; } if (sum < amountSat) throw new Error(this._insufficientMsg(tip)); if (picked.length > 24) throw new Error('balance is spread over too many coins for one send'); return picked; } _insufficientMsg(tip) { const expiredSat = this.state.vtxos .filter((v) => v.state === 'spendable' && this._expired(v, tip)) .reduce((n, v) => n + v.amountSat, 0); return expiredSat ? `insufficient ark balance (${expiredSat} sat expired and unusable)` : 'insufficient ark balance'; } async send(addrString, amountSat) { const dest = decodeAddress(addrString); if (dest.arkId !== hex.encode(arkIdFromServerPubkey(this.serverPub))) { throw new Error('address belongs to a different ark server'); } const mailboxDelivery = dest.delivery.find((d) => d.type === 1); if (!mailboxDelivery) throw new Error('address has no mailbox delivery mechanism'); const inputs = this._selectInputs(amountSat, await this._tipMemo()); // Each input funds its own arkoor: the first n-1 are consumed whole, the // last carries the remainder plus any change. let remaining = amountSat; const parts = inputs.map((v) => { const destSat = Math.min(remaining, v.amountSat); remaining -= destSat; const changeSat = v.amountSat - destSat; return { inputId: v.id, destSat, changeSat, changeIndex: changeSat > 0 ? this.state.nextKeyIndex++ : null, }; }); const action = { id: `send-${Date.now()}`, type: 'send', step: 'created', parts, amountSat, destAddress: addrString, destPubkey: dest.userPubkey, destBlindedId: mailboxDelivery.data, }; for (const v of inputs) v.state = 'pending'; this.state.actions.push(action); this._save(); await this._driveSend(action); if (action.step !== 'done') throw new Error(action.error || 'Ark payment did not complete'); return action.id; } // pre-multi-input actions carried the single part in flat fields _sendParts(action) { return action.parts || [{ inputId: action.inputId, destSat: action.amountSat, changeSat: action.changeSat || 0, changeIndex: action.changeIndex, }]; } async _driveSend(action) { if (action.step === 'created') { const parts = this._sendParts(action); const inputRecs = parts.map((p) => this._vtxo(p.inputId)); const decoded = inputRecs.map((r) => this._decoded(r)); const keysList = inputRecs.map((r) => this._keyForVtxo(r)); await registerVtxoTransactions(this.arkUrl, decoded.map((d) => d._raw.bytes)); const builds = parts.map((p, i) => buildArkoorSend({ input: decoded[i], outputs: [ { amountSat: p.destSat, userPubkey: hex.decode(action.destPubkey) }, ...(p.changeSat > 0 ? [{ amountSat: p.changeSat, userPubkey: this._key(p.changeIndex).pubkey }] : []), ], serverPubkey: this.serverPub, vtxoKeys: keysList[i], })); let sigsList; try { // one atomic package: the server signs every part or none sigsList = await cosignPackageWithServer(this.arkUrl, builds.map((b, i) => ({ build: b, input: decoded[i], vtxoKeys: keysList[i] })), this.serverPub); } catch (e) { const spent = e instanceof GrpcError && /already spent|not spendable/i.test(e.message); if (spent || (e instanceof GrpcError && e.grpcStatus === 3)) { // Status 3: the server rejected the whole package — nothing was // spent, everything returns to spendable. "already spent" names ONE // stale input but not which: optimistically free them all and let // reconcile() flip the truly-gone one back to spent. for (const r of inputRecs) r.state = 'spendable'; if (spent) this.reconcile().catch(() => {}); action.step = 'failed'; action.error = e.message; this._recordExpiryRejection(action); this._movement({ type: 'send', amountSat: action.amountSat, status: 'failed', detail: e.message, to: action.destAddress }); this._save(); return; } throw e; } // the inputs are spent server-side from this moment: persist immediately. // Dust padding may SPLIT a destination or change across two vtxos — // classify results by policy key rather than by output index. const destBytesList = []; const changeBytesList = []; for (let i = 0; i < parts.length; i++) { const all = buildAllSignedVtxos({ input: decoded[i], build: builds[i], finalSigs: sigsList[i], serverPubkey: this.serverPub }) .map((bytes) => ({ bytes, v: decodeVtxo(bytes) })); for (const x of all) { if (x.v.policy.userPubkey === action.destPubkey) destBytesList.push(hex.encode(x.bytes)); else { changeBytesList.push(hex.encode(x.bytes)); this._addVtxo(x.v, x.bytes, parts[i].changeIndex, 'pending'); } } inputRecs[i].state = 'spent'; } action.destBytesList = destBytesList; action.changeBytesList = changeBytesList; action.step = 'cosigned'; this._save(); } // legacy single-field actions (pre-dust-isolation) resume transparently const destList = () => action.destBytesList || [action.destBytes]; const changeList = () => action.changeBytesList || (action.changeBytes ? [action.changeBytes] : []); if (action.step === 'cosigned') { await registerVtxoTransactions(this.arkUrl, [...destList(), ...changeList()].map((b) => hex.decode(b))); action.step = 'registered'; this._save(); } if (action.step === 'registered') { // a pouch-bound send has no mailbox to deliver to — the caller takes // the bytes from the returned action instead if (action.destBlindedId) { await postArkoorMessage(this.arkUrl, hex.decode(action.destBlindedId), destList().map((b) => hex.decode(b))); } for (const b of changeList()) { const change = this._vtxo(decodeVtxo(hex.decode(b)).id); if (change && change.state === 'pending') change.state = 'spendable'; } action.step = 'done'; this._movement({ type: 'send', amountSat: action.amountSat, status: 'complete', to: action.destAddress, vtxoId: decodeVtxo(hex.decode(destList()[0])).id, // which coins this spend consumed — synced, so other devices can // skip the "spent on another device" row these inputs would earn inputIds: (action.parts || []).map((p2) => p2.inputId).filter(Boolean), }); this._save(); } } // ---- 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; } // The settled outcome of a payment by hash, polled until the ASP's node // decides or the wait runs out. For a payment another device of this // wallet started (the server refuses a second HTLC for the same invoice) // this is the only way to learn whether it paid. async lnOutcome(paymentHash, timeoutMs = 60000) { const deadline = Date.now() + timeoutMs; for (;;) { const res = await checkLightningPayment(this.arkUrl, hex.decode(paymentHash), false).catch(() => null); if (res && res.status === 'success' && res.preimage && hex.encode(sha256(hex.decode(res.preimage))) === paymentHash) return res; if (res && res.status === 'failed') return res; if (Date.now() >= deadline) return { status: 'pending' }; await new Promise((r) => setTimeout(r, 1500)); } } // Wait on the server for a Lightning send to settle or fail, then drive // the action to its terminal step. One held call instead of polling every // couple of seconds — the preimage reaches the caller the moment CLN has // it. A broken long call (proxy timeout, network) just returns the action // as it stands, and the caller's polling takes over. async awaitLnPay(id, timeoutMs = 90_000) { const a = this.lnAction(id); if (!a || a.step !== 'initiated') return this.driveLn(id); await Promise.race([ checkLightningPayment(this.arkUrl, hex.decode(a.paymentHash), true).catch(() => null), new Promise((r) => setTimeout(r, timeoutMs)), ]); return this.driveLn(id); } 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; } // Routing fee the user must bring for this payment. Non-zero only when the // server prices lightning sends at zero — then whatever the HTLC locks // beyond the amount becomes CLN's routing budget, and the quote service // reports what the route actually costs (0 between wallets on this ASP and // to direct peers). When the quote can't be reached we fall back to the old // flat pricing so payments keep working, at worst overpaying a few sats. async lnRouteFee(invoice, amountSat) { const f = this.info.lnSendFees || {}; const zeroPriced = !f.minFeeSat && !f.baseFeeSat && !(f.ppmExpiryTable || []).length; if (!zeroPriced || !this.lnQuoteUrl) return 0; try { return await fetchLnRouteFee(this.lnQuoteUrl, invoice, amountSat); } catch { return Math.max(3, Math.ceil(amountSat / 1000)); } } // Fetch a BOLT 12 invoice for an offer through the server's CLN, then // verify it HERE: hash and amount are read out of the raw TLVs and the // issuer's signature is checked, so the server can't hand back an invoice // whose preimage it already holds. Returns the lni string payLnInvoice // (and captaind) accept. async fetchBolt12(offerStr, amountSat) { const offer = decodeOffer(offerStr); if (offer.currency) throw new Error(`offer is priced in ${offer.currency}, not sats`); const bytes = await fetchBolt12Invoice(this.arkUrl, offer.bytes, amountSat); const inv = decodeBolt12Invoice(bytes); verifyOfferInvoice(offer, inv); if (amountSat && inv.amountSat !== amountSat) throw new Error('invoice amount does not match'); if (inv.expiresAt && inv.expiresAt < Date.now() + 60_000) throw new Error('invoice already expired'); return { invoice: encodeBech32Raw('lni', bytes), amountSat: inv.amountSat, paymentHash: inv.paymentHash, description: inv.description, }; } // Pay a bolt11 or bolt12 invoice with ark funds. Returns the action id; // drive to a terminal step ('done' | 'failed') via driveLn()/sync. async payLnInvoice(invoice, { amountSat: userAmountSat, routingFeeSat } = {}) { const dec = decodeLnInvoice(invoice); const expectNet = { bitcoin: 'mainnet', regtest: 'regtest', signet: 'signet', testnet: 'testnet' }[this.info.network]; // a null network is a bolt12 on a chain we don't recognize (custom // signets) — the server rejects a true mismatch, so don't guess here if (expectNet && dec.network && 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(); const routing = routingFeeSat != null ? routingFeeSat : await this.lnRouteFee(invoice, amountSat); // smallest single vtxo that covers amount + routing + its expiry-dependent // fee — else gather largest-first: each input funds its own HTLC vtxo with // the same payment hash, and the server sums them into one payment. const spendable = this.state.vtxos .filter((v) => v.state === 'spendable' && !this._expired(v, tip)); let inputs = null, feeSat = 0; for (const v of [...spendable].sort((a, b) => a.amountSat - b.amountSat)) { const fee = lnSendFee(amountSat, this.info.lnSendFees, [v], tip) + routing; if (v.amountSat >= amountSat + fee) { inputs = [v]; feeSat = fee; break; } } if (!inputs) { const picked = []; let sum = 0; for (const v of [...spendable].sort((a, b) => b.amountSat - a.amountSat)) { picked.push(v); sum += v.amountSat; feeSat = lnSendFee(amountSat, this.info.lnSendFees, picked, tip) + routing; if (sum >= amountSat + feeSat) { inputs = picked; break; } } if (!inputs) throw new Error(this._insufficientMsg(tip)); if (inputs.length > 24) throw new Error('balance is spread over too many coins for one payment'); } // per-input HTLC share: first n-1 consumed whole, the last carries change let remainingLock = amountSat + feeSat; const parts = inputs.map((v) => { const htlcSat = Math.min(remainingLock, v.amountSat); remainingLock -= htlcSat; return { inputId: v.id, htlcSat, changeSat: v.amountSat - htlcSat }; }); const action = { id: `lnpay-${Date.now()}`, type: 'ln-pay', step: 'created', invoice, paymentHash: dec.paymentHash, amountSat, feeSat, routingFeeSat: routing, parts, htlcKeyIndex: this.state.nextKeyIndex++, // HTLC lock key, reused for change (bark does the same) revKeyIndex: this.state.nextKeyIndex++, htlcExpiry: tip + (this.info.htlcSendExpiryDelta || 258), }; for (const v of inputs) v.state = 'pending'; this.state.actions.push(action); this._save(); await this._driveLnPay(action); return action.id; } // pre-multi-input ln-pay actions carried the single part in flat fields _lnParts(action) { return action.parts || [{ inputId: action.inputId, htlcSat: action.amountSat + (action.feeSat || 0), changeSat: action.changeSat || 0, }]; } 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 parts = this._lnParts(action); const inputRecs = parts.map((p) => this._vtxo(p.inputId)); const decoded = inputRecs.map((r) => this._decoded(r)); const keysList = inputRecs.map((r) => this._keyForVtxo(r)); const htlcKey = this._key(action.htlcKeyIndex); // every part locks its own HTLC vtxo under the SAME payment hash and // key — the server sums them into one payment (htlc_vtxo_sum) const builds = parts.map((p, i) => buildArkoorSend({ input: decoded[i], outputs: [ { amountSat: p.htlcSat, policy: { type: 'serverHtlcSend', userPubkey: hex.encode(htlcKey.pubkey), paymentHash: action.paymentHash, htlcExpiry: action.htlcExpiry, }, }, ...(p.changeSat > 0 ? [{ amountSat: p.changeSat, userPubkey: htlcKey.pubkey }] : []), ], serverPubkey: this.serverPub, vtxoKeys: keysList[i], })); await registerVtxoTransactions(this.arkUrl, decoded.map((d) => d._raw.bytes)); const noncesList = builds.map((b, i) => genUserNonces(b, keysList[i])); let resps; try { // idempotent on payment hash server-side: a re-drive after a crash // gets fresh partials for fresh nonces. One package: all or none. resps = await requestLightningPayHtlcCosign(this.arkUrl, builds.map((b, i) => cosignPartBytes({ build: b, input: decoded[i], vtxoKeys: keysList[i], nonces: noncesList[i] }))); } catch (e) { // A stale local tip under-shoots the HTLC expiry, and the server's // error helpfully names ITS tip — adopt it and let the next drive // retry with a valid expiry instead of failing the whole payment. const tipM = e instanceof GrpcError && e.message.match(/expiry is too low.*?our tip is (\d+)/i); if (tipM) { const serverTip = parseInt(tipM[1], 10); action.htlcExpiry = serverTip + (this.info.htlcSendExpiryDelta || 258); this._tipH = Math.max(this._tipH || 0, serverTip); this._tipAt = Date.now(); this._save(); throw new Error('chain tip was stale — retrying with the corrected expiry'); } // INVALID_ARGUMENT (status 3): the server rejected the package outright // — nothing was spent, everything returns to spendable. "already // spent" names ONE stale input but not which: free them all and let // reconcile() flip the truly-gone one back to spent. const spent = e instanceof GrpcError && /already spent|not spendable/i.test(e.message); if (spent || (e instanceof GrpcError && e.grpcStatus === 3)) { for (const r of inputRecs) r.state = 'spendable'; if (spent) this.reconcile().catch(() => {}); action.step = 'failed'; action.error = e.message; this._recordExpiryRejection(action); // "already in progress" means another device of this wallet is // paying this very invoice — that payment's row is the history, // a failed row beside it would read as a lost zap. if (!/already in progress/i.test(e.message)) { this._movement({ type: 'ln-send', amountSat: action.amountSat, status: 'failed', detail: e.message }); } this._save(); return; } throw e; } if (resps.length !== parts.length) throw new Error('bad cosign response count'); // the inputs are spent server-side from this moment: persist immediately. // Dust padding may split an HTLC (or change) across two vtxos — // classify by policy type. const htlcBytesList = [], htlcVtxoIds = [], changeBytesList = []; for (let i = 0; i < parts.length; i++) { const sigs = combineCosign({ build: builds[i], nonces: noncesList[i], serverResp: resps[i], vtxoKeys: keysList[i], serverPubkey: this.serverPub, }); const all = buildAllSignedVtxos({ input: decoded[i], build: builds[i], finalSigs: sigs, serverPubkey: this.serverPub }) .map((bytes) => ({ bytes, v: decodeVtxo(bytes) })); for (const x of all) { if (x.v.policy.type === 'serverHtlcSend') { htlcBytesList.push(hex.encode(x.bytes)); htlcVtxoIds.push(x.v.id); } else changeBytesList.push(hex.encode(x.bytes)); this._addVtxo(x.v, x.bytes, action.htlcKeyIndex, 'pending'); } inputRecs[i].state = 'spent'; } action.htlcBytesList = htlcBytesList; action.htlcVtxoIds = htlcVtxoIds; action.changeBytesList = changeBytesList; action.step = 'cosigned'; this._save(); } // legacy single-field actions resume transparently const htlcList = () => action.htlcBytesList || [action.htlcBytes]; const htlcIds = () => action.htlcVtxoIds || [action.htlcVtxoId]; const changeList = () => action.changeBytesList || (action.changeBytes ? [action.changeBytes] : []); if (action.step === 'cosigned') { await registerVtxoTransactions(this.arkUrl, [...htlcList(), ...changeList()].map((b) => hex.decode(b))).catch(() => {}); // best-effort (bark warns too) // the change is a plain cosigned vtxo, good regardless of what the // payment does from here for (const b of changeList()) { const change = this._vtxo(decodeVtxo(hex.decode(b)).id); if (change && change.state === 'pending') change.state = 'spendable'; } try { await initiateLightningPayment(this.arkUrl, { invoice: action.invoice, htlcVtxoIdRaws: htlcList().map((b) => decodeVtxo(hex.decode(b)).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) { // The mailbox push and the status poll both land here, and their step // checks happen BEFORE awaits — settle once, however many callers race // (a hat purchase once wrote its history line twice this way). if (action.step === 'done') return; for (const id of action.htlcVtxoIds || [action.htlcVtxoId]) { const htlc = this._vtxo(id); if (htlc) htlc.state = 'spent'; } action.preimage = preimageHex; action.step = 'done'; const dup = this.state.movements.some((m) => m.type === 'ln-send' && (m.paymentHash === action.paymentHash || (m.preimage && m.preimage === preimageHex))); if (!dup) this._movement({ type: 'ln-send', amountSat: action.amountSat, status: 'complete', detail: action.feeSat ? `fee ${action.feeSat} sat` : undefined, invoice: action.invoice, preimage: preimageHex, paymentHash: action.paymentHash, inputIds: (action.parts || []).map((p2) => p2.inputId).concat(action.inputId ? [action.inputId] : []).filter(Boolean), }); 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 keys = this._key(action.htlcKeyIndex); const revKey = this._key(action.revKeyIndex); const inputs = (action.htlcBytesList || [action.htlcBytes]) .map((b) => decodeVtxo(hex.decode(b))); const builds = inputs.map((input) => buildArkoorSend({ input, outputs: [{ amountSat: input.amountSat, userPubkey: revKey.pubkey }], serverPubkey: this.serverPub, vtxoKeys: keys, })); const noncesList = builds.map((b) => genUserNonces(b, keys)); const parts = builds.map((b, i) => cosignPartBytes({ build: b, input: inputs[i], vtxoKeys: keys, nonces: noncesList[i] })); const resps = await requestLightningPayHtlcRevocation(this.arkUrl, parts); if (resps.length !== builds.length) throw new Error('bad revocation cosign response'); for (let i = 0; i < builds.length; i++) { const sigs = combineCosign({ build: builds[i], nonces: noncesList[i], serverResp: resps[i], vtxoKeys: keys, serverPubkey: this.serverPub, }); const outBytes = buildAllSignedVtxos({ input: inputs[i], build: builds[i], finalSigs: sigs, serverPubkey: this.serverPub, }); await registerVtxoTransactions(this.arkUrl, outBytes).catch(() => {}); const htlcRec = this._vtxo(inputs[i].id); if (htlcRec) htlcRec.state = 'spent'; for (const bts of outBytes) this._addVtxo(decodeVtxo(bts), bts, 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); // anchor the requested expiry on the FIRST attempt: the server's grant // is fixed by the inbound HTLC, so recomputing from a later tip would // inflate our own requirement until it exceeds the grant if (!action.htlcRecvExpiry) { action.htlcRecvExpiry = tip + action.minCltvDelta; this._save(); } const htlcRecvExpiry = action.htlcRecvExpiry; 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'); try { await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, allowPolicies: ['serverHtlcRecv'] }); } catch (e) { // a granted vtxo can anchor to a round the chain hasn't confirmed // yet — park (no preimage revealed, nothing at risk) and retry on // sync; the invoice-expiry reap bounds how long we'll wait if (e instanceof VtxoValidationError) { action.lastError = e.message; this._save(); return; } throw e; } 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); // ClaimLightningReceive matches cosign parts to its HTLC list // positionally, ordered by OUTPOINT IN INTERNAL BYTE ORDER (Rust's // natural OutPoint sort). Sorting by display-hex id — reversed txid — // ordered them differently and the claim bounced with "cosign inputs // do not match htlcs" (bites only multi-vtxo receives; verified live // against a 9-vtxo 215k receive). const byOutpoint = (a, b) => { for (let i = 0; i < 32; i++) { if (a.point.raw[i] !== b.point.raw[i]) return a.point.raw[i] - b.point.raw[i]; } return a.point.vout - b.point.vout; }; const decoded = action.htlcBytes.map((b) => decodeVtxo(hex.decode(b))).sort(byOutpoint); // 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(); const minExpiry = Math.min(...decoded.map((v) => v.policy.htlcExpiry)); if (tip > minExpiry - 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 buildAllSignedVtxos({ input: decoded[i], build: b, finalSigs: sigs, serverPubkey: this.serverPub })[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(); } } // ---- third-party HTLCs (trustless swaps with a non-ASP counterparty) ---- // These are the primitives a submarine swap with an external bridge needs: // lock funds into an HTLC only the counterparty can claim (with the // preimage) and only we can reclaim (after expiry). Requires a server that // supports VtxoPolicy::Htlc — see docs/third-party-htlc.md. // Lock `amountSat` into an HTLC claimable by `claimPubkey` against // `paymentHash`, refundable by us after `htlcExpiry`. Returns the signed // HTLC vtxo bytes (hand these to the counterparty) plus our refund key // index so the refund can be driven later. async htlcLock({ amountSat, claimPubkey, paymentHash, htlcExpiry }) { const input = this._selectInput(amountSat); const inputRec = input; const decoded = this._decoded(inputRec); const keys = this._keyForVtxo(inputRec); const refundIndex = this.state.nextKeyIndex++; const changeSat = inputRec.amountSat - amountSat; const outputs = [{ amountSat, policy: { type: 'htlc', claimPubkey: typeof claimPubkey === 'string' ? claimPubkey : hex.encode(claimPubkey), refundPubkey: hex.encode(this._key(refundIndex).pubkey), paymentHash: typeof paymentHash === 'string' ? paymentHash : hex.encode(paymentHash), htlcExpiry, }, }]; if (changeSat > 0) outputs.push({ amountSat: changeSat, userPubkey: this._key(refundIndex).pubkey }); await registerVtxoTransactions(this.arkUrl, [decoded._raw.bytes]); const build = buildArkoorSend({ input: decoded, outputs, serverPubkey: this.serverPub, vtxoKeys: keys }); const sigs = await cosignWithServer(this.arkUrl, build, { input: decoded, vtxoKeys: keys, serverPubkey: this.serverPub, }); const all = buildAllSignedVtxos({ input: decoded, build, finalSigs: sigs, serverPubkey: this.serverPub }) .map((bytes) => ({ bytes, v: decodeVtxo(bytes) })); const htlcs = all.filter((x) => x.v.policy.type === 'htlc'); const change = all.filter((x) => x.v.policy.type !== 'htlc'); inputRec.state = 'spent'; // the HTLC output isn't ours to spend (only to refund), so it is NOT // added to the wallet's spendable set; change is for (const x of change) this._addVtxo(x.v, x.bytes, refundIndex); await registerVtxoTransactions(this.arkUrl, all.map((x) => x.bytes)).catch(() => {}); this._movement({ type: 'htlc-lock', amountSat, status: 'complete', detail: `htlc expiry ${htlcExpiry}`, }); this._save(); return { htlcVtxos: htlcs.map((x) => hex.encode(x.bytes)), refundIndex, htlcExpiry, }; } // Claim an HTLC vtxo made out to one of our keys by revealing `preimage`. // The server verifies the hash lock before cosigning, and records the // preimage for the counterparty to fetch. async htlcClaim({ htlcVtxoBytes, claimKeyIndex, preimage, destPubkey }) { const input = decodeVtxo(hex.decode(htlcVtxoBytes)); if (input.policy.type !== 'htlc') throw new Error('not an htlc vtxo'); const keys = this._key(claimKeyIndex); if (input.policy.claimPubkey !== hex.encode(keys.pubkey)) throw new Error('htlc not claimable by this key'); if (hex.encode(sha256(hex.decode(preimage))) !== input.policy.paymentHash) { throw new Error('preimage does not match the htlc payment hash'); } const outIndex = destPubkey ? null : this.state.nextKeyIndex++; const outPub = destPubkey ? (typeof destPubkey === 'string' ? hex.decode(destPubkey) : destPubkey) : this._key(outIndex).pubkey; await registerVtxoTransactions(this.arkUrl, [input._raw.bytes]).catch(() => {}); const build = buildArkoorSend({ input, outputs: [{ amountSat: input.amountSat, userPubkey: outPub }], serverPubkey: this.serverPub, vtxoKeys: keys, }); const sigs = await cosignWithServer(this.arkUrl, build, { input, vtxoKeys: keys, serverPubkey: this.serverPub, preimage, }); const outBytes = buildAllSignedVtxos({ input, build, finalSigs: sigs, serverPubkey: this.serverPub }); await registerVtxoTransactions(this.arkUrl, outBytes).catch(() => {}); if (outIndex != null) { for (const b of outBytes) this._addVtxo(decodeVtxo(b), b, outIndex); this._movement({ type: 'htlc-claim', amountSat: input.amountSat, status: 'complete' }); this._save(); } return outBytes.map((b) => hex.encode(b)); } // Cooperatively hand an HTLC back to its refunder. Called by the CLAIMER // (who holds the taproot keyspend path with the server) — typically a swap // provider releasing a swap it couldn't complete. // // This is safe to expose publicly: the server only cosigns a preimage-less // spend of an HTLC when every output pays the refund pubkey, so the claimer // has no way to redirect the funds. If the claimer never calls this, the // refunder's guaranteed fallback is a unilateral exit through the refund // tapscript leaf after `htlcExpiry` — no permission required. async htlcCosignRefund({ htlcVtxoBytes, claimKeyIndex }) { const input = decodeVtxo(hex.decode(htlcVtxoBytes)); if (input.policy.type !== 'htlc') throw new Error('not an htlc vtxo'); const keys = this._key(claimKeyIndex); if (input.policy.claimPubkey !== hex.encode(keys.pubkey)) throw new Error('htlc not held by this key'); await registerVtxoTransactions(this.arkUrl, [input._raw.bytes]).catch(() => {}); const build = buildArkoorSend({ input, outputs: [{ amountSat: input.amountSat, userPubkey: hex.decode(input.policy.refundPubkey) }], serverPubkey: this.serverPub, vtxoKeys: keys, }); const sigs = await cosignWithServer(this.arkUrl, build, { input, vtxoKeys: keys, serverPubkey: this.serverPub, // no preimage: refund-only }); const outBytes = buildAllSignedVtxos({ input, build, finalSigs: sigs, serverPubkey: this.serverPub }); await registerVtxoTransactions(this.arkUrl, outBytes).catch(() => {}); return outBytes.map((b) => hex.encode(b)); } // Take ownership of refunded HTLC vtxos handed back by the claimer (they // pay our refund key, so they're ours to spend once validated). async acceptHtlcRefund({ vtxoBytesList, refundIndex }) { const expect = hex.encode(this._key(refundIndex).pubkey); let total = 0; for (const b of vtxoBytesList) { const bytes = hex.decode(b); const v = decodeVtxo(bytes); if (v.policy.type !== 'pubkey' || v.policy.userPubkey !== expect) { throw new Error('refund vtxo is not paid to our refund key'); } await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: [expect] }); if (this._addVtxo(v, bytes, refundIndex)) total += v.amountSat; } if (total) { this._movement({ type: 'htlc-refund', amountSat: total, status: 'complete' }); this._save(); } return total; } // ---- board ---- // Returns the onchain funding address; the onchain wallet pays it (the // board output MUST be vout 0), then completeBoard(actionId, txid). async startBoard(amountSat) { if (amountSat < this.info.minBoardAmountSat) { throw new Error(`board minimum is ${this.info.minBoardAmountSat} sat`); } const feeSat = boardFee(amountSat, this.info.boardFees); const tip = await this.chain.tipHeight(); const action = { id: `board-${Date.now()}`, type: 'board', step: 'created', amountSat, feeSat, expiryHeight: tip + this.info.vtxoExpiryDelta, keyIndex: this.state.nextKeyIndex++, }; this.state.actions.push(action); this._save(); const keys = this._key(action.keyIndex); const probe = buildBoard({ userPub: keys.pubkey, serverPub: this.serverPub, amountSat, feeSat, expiryHeight: action.expiryHeight, exitDelta: this.info.vtxoExitDelta, fundingOutpointRaw: new Uint8Array(36), }); const hrp = { bitcoin: 'bc', regtest: 'bcrt' }[this.info.network] || 'tb'; return { actionId: action.id, fundingAddress: p2trAddress(probe.fundingTaproot.outputXOnly, hrp), feeSat }; } async completeBoard(actionId, fundingTxid) { const action = this.state.actions.find((a) => a.id === actionId); if (!action || action.type !== 'board') throw new Error('unknown board action'); if (action.step === 'created') { action.fundingTxid = fundingTxid; action.step = 'funded'; this._save(); } await this._driveBoard(action); return action; } async _driveBoard(action) { if (action.step === 'created') return; // waiting for funding txid const keys = this._key(action.keyIndex); if (action.step === 'funded') { const need = this.info.requiredBoardConfirmations; let status = null, statusErr = null; try { status = await this.chain.getTxStatus(action.fundingTxid); } catch (e) { statusErr = e; } // The wallet's own chain view can vouch when the ark esplora can't // (rate limits, bot walls) — both watch the same network. if (!status?.confirmed && this.statusHint) { const hint = this.statusHint(action.fundingTxid); if (hint?.confirmed) { status = hint; statusErr = null; } } if (!status && statusErr) throw statusErr; // recorded as lastError → visible notice // record progress so the UI can say WHERE the wait is (n/need confs). // A confirmed status proves at least ONE confirmation on its own — the // tip fetch only refines the count, so its failure (rate limits) must // not strand a confirmed board at "0/need" forever. let confs = 0; if (status?.confirmed) { const tip = await this.chain.tipHeight().catch(() => null); const delta = tip != null ? tip - status.block_height + 1 : NaN; confs = Number.isFinite(delta) && delta > 0 ? delta : 1; } if (confs !== action.confs || need !== action.needConfs) { action.confs = confs; action.needConfs = need; this._save(); } if (!(confs >= need)) return; // retried on sync const fundingOutpointRaw = new Uint8Array(36); fundingOutpointRaw.set(hex.decode(action.fundingTxid).reverse(), 0); // vout 0 const board = buildBoard({ userPub: keys.pubkey, serverPub: this.serverPub, amountSat: action.amountSat, feeSat: action.feeSat, expiryHeight: action.expiryHeight, exitDelta: this.info.vtxoExitDelta, fundingOutpointRaw, }); const nonces = musig2.nonceGen(keys.pubkey, keys.privkey, undefined, board.sighash); const fundingTxHex = await this.chain.getTxHex(action.fundingTxid); const cosign = await requestBoardCosign(this.arkUrl, { amountSat: action.amountSat, fundingOutpointRaw, expiryHeight: action.expiryHeight, userPub: keys.pubkey, pubNonce: nonces.public, fundingTxBytes: fundingTxHex ? hex.decode(fundingTxHex.trim()) : undefined, }); const finalSig = combineBoardSignature({ board, serverCosign: cosign, userNonces: nonces, vtxoKeys: keys, serverPub: this.serverPub }); action.vtxoBytes = hex.encode(encodeBoardVtxo({ userPub: keys.pubkey, serverPub: this.serverPub, amountSat: action.amountSat, feeSat: action.feeSat, expiryHeight: action.expiryHeight, exitDelta: this.info.vtxoExitDelta, fundingOutpointRaw, exitTxidInternal: txid(board.exitTx), finalSig, })); action.step = 'cosigned'; this._save(); } if (action.step === 'cosigned') { const bytes = hex.decode(action.vtxoBytes); await registerBoardVtxo(this.arkUrl, bytes); const decoded = decodeVtxo(bytes); this._addVtxo(decoded, bytes, action.keyIndex); action.step = 'done'; this._movement({ type: 'board', amountSat: decoded.amountSat, status: 'complete', txid: action.fundingTxid, vtxoId: decoded.id }); this._save(); } } // ---- refresh (also the consolidation primitive) ---- refreshFee(inputs, tip) { // pver >= 4: ppm accumulates sub-satoshi across ALL inputs and rounds UP // once at the end (the server's calc_ppm_expiry_fee). Per-input floor — // the old way — undercharges and the server rejects the round intent. const table = this.info.refreshFees.ppmExpiryTable; let ppmUnits = 0; for (const v of inputs) { const blocks = v.expiryHeight - tip; const entry = table.filter((e) => e.thresholdBlocks <= blocks).pop(); ppmUnits += v.amountSat * (entry?.ppm ?? 0); } return this.info.refreshFees.baseFeeSat + Math.ceil(ppmUnits / 1_000_000); } async refresh(vtxoIds) { const inputs = (vtxoIds ? vtxoIds.map((id) => this._vtxo(id)) : this.state.vtxos.filter((v) => v.state === 'spendable')); if (!inputs.length || inputs.some((v) => !v || v.state !== 'spendable')) { throw new Error('no spendable vtxos to refresh'); } const tip = await this.chain.tipHeight(); const totalSat = inputs.reduce((n, v) => n + v.amountSat, 0); const feeSat = this.refreshFee(inputs, tip); // The server refuses round outputs under P2TR dust (330), so a wallet // this small can't renew — fail before the action marks coins pending. if (totalSat - feeSat < 330) { throw new Error(`balance too small to refresh: ${totalSat} sat minus ${feeSat} sat fee is under the 330 sat minimum`); } const action = { id: `refresh-${Date.now()}`, type: 'refresh', step: 'created', inputIds: inputs.map((v) => v.id), outKeyIndex: this.state.nextKeyIndex++, outAmountSat: totalSat - feeSat, feeSat, }; for (const v of inputs) v.state = 'pending'; this.state.actions.push(action); this._save(); await this._driveRefresh(action); return action.id; } _refreshOutputs(action) { return [{ amountSat: action.outAmountSat, userPubkey: this._key(action.outKeyIndex).pubkey }]; } // Ask the server which of a rejected participation's inputs are dead, mark // those spent, and rebuild the action around the survivors — or fail it and // release them if nothing changed (the cause isn't a spent input) or what // remains is under the server's output minimum. async _repairRefresh(action, inputRecs) { const states = await Promise.all(inputRecs.map((v) => getVtxoStatus(this.arkUrl, this._decoded(v).point.raw, this._keyForVtxo(v).privkey) .catch(() => null))); const live = []; inputRecs.forEach((v, i) => { if (states[i] === VTXO_STATE_SPENT) { v.state = 'spent'; this._movement({ type: 'reconcile', amountSat: v.amountSat, status: 'complete', vtxoId: v.id, detail: 'spent elsewhere (server vtxo status)' }); } else live.push(v); }); const dropped = inputRecs.length - live.length; const tip = await this.chain.tipHeight(); const totalSat = live.reduce((n, v) => n + v.amountSat, 0); const feeSat = live.length ? this.refreshFee(live, tip) : 0; if (dropped && live.length && totalSat - feeSat >= 330) { action.inputIds = live.map((v) => v.id); action.outAmountSat = totalSat - feeSat; action.feeSat = feeSat; // still 'created': the next sync submits the repaired set } else { for (const v of live) v.state = 'spendable'; action.step = 'failed'; action.lastError = dropped ? 'refresh inputs were spent elsewhere' : 'server rejected refresh inputs'; } this._save(); } // A refresh claim can be reached from two directions at once — the sync // loop resuming the action, and a mailbox breadcrumb claiming the moment // the round is announced. Both are safe server-side (cosign and forfeit are // idempotent), but two winners would write two "Renewed" rows. async _driveRefresh(action) { if (this._refreshDriving.has(action.id)) return; this._refreshDriving.add(action.id); try { await this._driveRefreshSteps(action); } finally { this._refreshDriving.delete(action.id); } } async _driveRefreshSteps(action) { const outputs = this._refreshOutputs(action); const inputRecs = action.inputIds.map((id) => this._vtxo(id)); if (action.step === 'created') { const inputBytes = inputRecs.map((v) => vtxoBytesFromStr(v.bytes)); await registerVtxoTransactions(this.arkUrl, inputBytes); let unlockHash; try { unlockHash = await submitRoundParticipation(this.arkUrl, { inputs: inputRecs.map((v) => ({ vtxo: this._decoded(v), keys: this._keyForVtxo(v) })), outputs, // recovery breadcrumb: the round posts our unlock hash to the // mailbox, so a lost action (crash, foreign device) is found and // claimed by whichever device reads the mailbox next mailboxId: this._mailboxKey().pubkey, }); } catch (e) { // "unusable inputs" never becomes usable by resubmission — some input // was spent elsewhere and this state didn't know. Repair instead of // retrying the same doomed set on every sync forever (which also // holds the inputs pending, blocking every future auto-refresh). if (!/unusable/i.test(e.message || '')) throw e; // transient: retry on next sync await this._repairRefresh(action, inputRecs); return; } action.unlockHash = hex.encode(unlockHash); action.step = 'submitted'; this._save(); } if (action.step === 'submitted') { const status = await roundParticipationStatus(this.arkUrl, hex.decode(action.unlockHash)); if (status.status === 0 || !status.fundingTx) return; // round pending; retry on next sync const fundingTx = parseTx(status.fundingTx); const confirmed = await this.chain.getTxStatus(fundingTx.txid); if (!confirmed?.confirmed) return; // wait for funding confirmations action.fundingTxHex = hex.encode(status.fundingTx); action.outputVtxos = status.outputVtxos.map((b) => hex.encode(b)); action.step = 'issued'; this._save(); } if (action.step === 'issued') { const outKeys = this._key(action.outKeyIndex); const fundingTx = parseTx(hex.decode(action.fundingTxHex)); const unlockHash = hex.decode(action.unlockHash); const newVtxos = action.outputVtxos.map((b) => decodeVtxo(hex.decode(b))); // leaf cosign + forfeit are both idempotent server-side const leafSigs = []; for (const v of newVtxos) { leafSigs.push(await cosignHarkLeaf(this.arkUrl, v, fundingTx, outKeys, this.serverPub)); } const serverNonces = await requestForfeitNonces(this.arkUrl, unlockHash, inputRecs.map((v) => this._decoded(v).point.raw)); const bundles = inputRecs.map((v, i) => forfeitBundle({ input: this._decoded(v), unlockHash, vtxoKeys: this._keyForVtxo(v), serverPub: this.serverPub, serverNonce: serverNonces[i], })); const preimage = await forfeitVtxos(this.arkUrl, bundles); if (hex.encode(sha256(preimage)) !== action.unlockHash) { throw new Error('unlock preimage does not match hash'); } for (let i = 0; i < newVtxos.length; i++) { const v = newVtxos[i]; const last = v.genesis[v.genesis.length - 1]; last.transition.signature = hex.encode(leafSigs[i]); last.transition.unlock = { preimage: hex.encode(preimage) }; const bytes = encodeVtxoFromDecoded(v); this._addVtxo(decodeVtxo(bytes), bytes, action.outKeyIndex); } for (const v of inputRecs) v.state = 'spent'; action.step = 'done'; // unlockHash keys the cross-device display fold (two devices adopting // the same participation must not become two "Renewed" rows); // inputIds let the history skip "spent elsewhere" rows for coins this // renewal consumed — reconciled before the claim, they'd otherwise // stand as spends that the renewed coin silently contradicts. this._movement({ type: 'refresh', amountSat: action.outAmountSat, feeSat: action.feeSat || 0, status: 'complete', unlockHash: action.unlockHash, inputIds: [...(action.inputIds || [])], detail: `${inputRecs.length} in -> ${newVtxos.length} out${action.feeSat ? ` · fee ${action.feeSat} sat` : ''}` }); this._save(); } } // ---- scheduled refresh: the renewal that happens without us ---- // // A participation registered now but held by the server until a future // block height, then run in a round with nobody online (the round tree // branch is signed by the server — that is what makes a delegated refresh // delegated). The output waits 'unclaimed' until this wallet returns and // forfeits the inputs, which nothing about expiry blocks: by then the money // already sits in a fresh tree, anchored on-chain, with a new lifetime. // // This is the backstop under maybeAutoRefresh, whose whole premise is that // someone opens the app inside the last few hours of a coin's life. Many // people don't. // // Deliberately NOT an action: the coins stay spendable, nothing treats the // wallet as busy, and no history row appears until there is something to // say. Spend a coin and its standing order simply dies — the server drops a // participation whose input is gone, and so do we. async scheduleRefresh(vtxoIds, scheduledHeight) { const inputs = vtxoIds.map((id) => this._vtxo(id)); if (!inputs.length || inputs.some((v) => !v || v.state !== 'spendable')) { throw new Error('no spendable vtxos to schedule'); } if (inputs.some((v) => !v.expiryHeight || v.expiryHeight <= scheduledHeight)) { throw new Error('scheduled height is not before every input expiry'); } // Priced at the height it will RUN at — which is what the server checks, // and cheaper than today: the refresh brackets fall as a coin's remaining // lifetime does, and the last one is free. const totalSat = inputs.reduce((n, v) => n + v.amountSat, 0); const feeSat = this.refreshFee(inputs, scheduledHeight); if (totalSat - feeSat < 330) { throw new Error(`balance too small to refresh: ${totalSat} sat minus ${feeSat} sat fee is under the 330 sat minimum`); } const entry = { unlockHash: null, inputIds: inputs.map((v) => v.id), outKeyIndex: this.state.nextKeyIndex++, outAmountSat: totalSat - feeSat, feeSat, scheduledHeight, ts: Date.now(), }; this._save(); // burn the key index before it can be used, as every flow does await registerVtxoTransactions(this.arkUrl, inputs.map((v) => vtxoBytesFromStr(v.bytes))); const unlockHash = await submitRoundParticipation(this.arkUrl, { inputs: inputs.map((v) => ({ vtxo: this._decoded(v), keys: this._keyForVtxo(v) })), outputs: this._refreshOutputs(entry), mailboxId: this._mailboxKey().pubkey, scheduledHeight, }); entry.unlockHash = hex.encode(unlockHash); (this.state.scheduled ||= []).push(entry); this._save(); return entry; } scheduledRefreshes() { return (this.state.scheduled || []).slice(); } // Keep every coin covered by a standing order, `marginBlocks` before its // expiry. Coins arrive, get spent, get renewed — each new one needs its own // backstop while it still has the lifetime to register one. Caller owns the // policy (how much margin, how often to ask); this owns the bookkeeping. async ensureScheduledRefreshes(marginBlocks, maxCoins = 8) { const tip = await this._tipMemo(); if (!tip) return 0; const covered = new Set((this.state.scheduled || []).flatMap((s) => s.inputIds)); // A round output over the server's cap is refused when the round runs — // long after registration, with nobody there to hear it. Don't register // an order that is going to die that quietly. const cap = this.info.maxVtxoAmountSat || Infinity; const coins = this.state.vtxos .filter((v) => v.state === 'spendable' && v.expiryHeight && !covered.has(v.id) && !this._expired(v, tip) && v.expiryHeight - marginBlocks > tip && v.amountSat <= cap) .sort((a, b) => b.amountSat - a.amountSat) .slice(0, maxCoins); let n = 0; for (const v of coins) { // One participation per coin. A shared one dies whole the moment any // single input is spent, taking the untouched coins' cover with it. try { await this.scheduleRefresh([v.id], v.expiryHeight - marginBlocks); n++; } catch (e) { console.warn('ark: no backstop refresh for', v.id, '—', e.message); } } return n; } _dropScheduled(entry) { this.state.scheduled = (this.state.scheduled || []).filter((s) => s !== entry); this._save(); } // The round ran. From here it is an ordinary refresh claim — leaf cosign, // forfeit, adopt the output — and the inputs stop being spendable, because // the server has already issued their replacement. async _promoteScheduled(entry) { this.state.scheduled = (this.state.scheduled || []).filter((s) => s !== entry); const action = { id: `refresh-${Date.now()}`, type: 'refresh', step: 'submitted', inputIds: [...entry.inputIds], outKeyIndex: entry.outKeyIndex, outAmountSat: entry.outAmountSat, feeSat: entry.feeSat, unlockHash: entry.unlockHash, scheduledHeight: entry.scheduledHeight, }; for (const id of entry.inputIds) { const v = this._vtxo(id); if (v && v.state === 'spendable') v.state = 'pending'; } this.state.actions.push(action); this._save(); await this._driveRefresh(action); return action; } // Advance the standing orders: drop the dead ones, claim the ones whose // round has run. Costs an RPC only once a scheduled height is behind us. async _driveScheduled() { for (const entry of [...(this.state.scheduled || [])]) { const inputs = entry.inputIds.map((id) => this._vtxo(id)); // A spent input ends the order — the server drops its copy for the same // reason. 'pending' is not that: an action holding the coin may yet // fail and hand it back, and re-registering costs a round-trip. if (inputs.some((v) => !v || v.state === 'spent')) { this._dropScheduled(entry); continue; } if (inputs.some((v) => v.state !== 'spendable')) continue; const tip = this._tipH || 0; if (tip && tip < entry.scheduledHeight) continue; // Past the scheduled height the round can be hours out (it runs when // the server runs one), and sync comes round every few seconds. The // mailbox breadcrumb is what actually wakes the claim; this poll is the // fallback for a wallet that missed it, so it can be slow. const now = Date.now(); if (now - (this._schedPollAt.get(entry.unlockHash) || 0) < 120_000) continue; this._schedPollAt.set(entry.unlockHash, now); let status; try { status = await roundParticipationStatus(this.arkUrl, hex.decode(entry.unlockHash)); } catch (e) { // The server deletes a participation it can't run (an input spent // between rounds, a collision with another process holding the coin), // so an unknown unlock hash ends this standing order rather than // being retried forever. Anything else is transient. if (/unknown|not found|no such|doesn't exist/i.test(e.message || '')) this._dropScheduled(entry); continue; } if (status.status === 0 || !status.fundingTx) continue; // round hasn't run yet await this._promoteScheduled(entry); } } // Rebuild a refresh claim from its unlock hash — for a participation whose // action was lost (snapshot rollback, device wipe, crash mid-claim) after // its round ran, leaving the output stranded 'unclaimed' on the server. // The server replays the funding tx, output vtxos AND input ids; our key // index is rediscovered by scanning; the forfeit handshake needs the // original input records, which must still exist in this wallet's state // (already marked spent is fine). inputVtxoIds may be passed explicitly // (the operator recipe) — omitted, they come from the status response. async rescueParticipation(unlockHashHex, inputVtxoIds) { if (this.state.actions.some((a) => a.unlockHash === unlockHashHex && a.step !== 'failed')) { throw new Error('an action for this participation already exists — sync drives it'); } // One of ours, waiting on a scheduled height: it has its inputs and key // index already, so promote rather than rediscover them. const standing = (this.state.scheduled || []).find((s) => s.unlockHash === unlockHashHex); if (standing) return this._promoteScheduled(standing); const status = await roundParticipationStatus(this.arkUrl, hex.decode(unlockHashHex)); if (status.status === 0 || !status.fundingTx) throw new Error('the round for this participation has not run'); if (!inputVtxoIds?.length) inputVtxoIds = status.inputVtxoIds; if (!inputVtxoIds?.length) throw new Error('input vtxo ids required'); const missing = inputVtxoIds.filter((id) => !this._vtxo(id)); if (missing.length) throw new Error('input records not in this wallet: ' + missing.join(', ')); const outs = status.outputVtxos.map((b) => decodeVtxo(b)); if (!outs.length) throw new Error('participation has no outputs'); const want = new Set(outs.map((v) => v.policy.userPubkey)); let outKeyIndex = null; // The index was minted by the lost action, so scan a window past the counter. for (let i = 0; i < this.state.nextKeyIndex + 64 && outKeyIndex == null; i++) { if (want.has(hex.encode(this._key(i).pubkey))) outKeyIndex = i; } if (outKeyIndex == null) throw new Error('no wallet key matches the participation outputs'); if (outKeyIndex >= this.state.nextKeyIndex) this.state.nextKeyIndex = outKeyIndex + 1; const outSat = outs.reduce((n, v) => n + v.amountSat, 0); const inSat = inputVtxoIds.reduce((n, id) => n + (this._vtxo(id).amountSat || 0), 0); const action = { id: `refresh-rescue-${Date.now()}`, type: 'refresh', step: 'issued', inputIds: [...inputVtxoIds], outKeyIndex, outAmountSat: outSat, feeSat: Math.max(0, inSat - outSat), unlockHash: unlockHashHex, fundingTxHex: hex.encode(status.fundingTx), outputVtxos: status.outputVtxos.map((b) => hex.encode(b)), }; this.state.actions.push(action); this._save(); await this._driveRefresh(action); return this.state.actions.find((a) => a.id === action.id); } }