diff options
Diffstat (limited to 'src/ark/manager.js')
| -rw-r--r-- | src/ark/manager.js | 121 |
1 files changed, 95 insertions, 26 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index caece44..91b5f0a 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -92,6 +92,16 @@ const actionTs = (a) => Number(String(a.id || '').split('-').pop()) || 0; // claim recorded before it stamped them — the same amount within minutes). // Derived at read time, never deleted: a synced copy from another device // can't bring a twin back. +// Two receive rows that are one payment delivered as several coins (a gift +// held in two swept at once, a dust-padded split): the same mailbox message +// (`batch`), or for rows recorded before the stamp, the same delivery instant. +// A receive the registrar's receipts tagged ⚡ is its own payment. +export function sameDelivery(a, b) { + return !!a && !!b && a.type === 'receive' && b.type === 'receive' + && a.status === 'complete' && b.status === 'complete' && !a.viaLn && !b.viaLn + && (a.batch || b.batch ? a.batch === b.batch : !!a.ts && a.ts === b.ts); +} + export function lnFoldedIds(movements) { const out = new Set(); const recs = (movements || []).filter((m) => m.type === 'receive' && m.status === 'complete' && m.vtxoId); @@ -226,7 +236,7 @@ export function renewPolicy(tier, network) { } export class ArkManager { - constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint, honorsExpired = false, infoHint = null }) { + constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint, honorsExpired = false, infoHint = null, lnOldestFirst }) { this.account = account; // HDKey node; ark keys derived beneath it // The server's last published info, kept by the app: lets the wallet open // when the server can't be reached (see init) @@ -248,6 +258,9 @@ export class ArkManager { : network === 'mutinynet' ? 'https://names.halwallet.app/lnquote?network=mutinynet' : null; this.onUpdate = onUpdate || (() => {}); + // () => true when this wallet renews its own coins (custody tier self or + // exit): a Lightning send then spends the coins nearest expiry first + this.lnOldestFirst = lnOldestFirst || (() => false); this.state = null; this.info = null; this._lnDriving = new Set(); // re-entrancy guard: sync poll vs UI fast-poll vs mailbox push @@ -805,9 +818,13 @@ export class ArkManager { // 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; + // `batch` names the mailbox message: one payment can arrive as several + // coins (a sweep of a gift held in two, a dust-padded split), and the + // history folds a message's coins back into one row this._movement({ type: 'receive', amountSat: v.amountSat, status: 'complete', vtxoId: v.id, ts: ckTs ?? (live ? Date.now() : this._estimateReceiveTs(v, tip)), + ...(m.checkpoint ? { batch: String(m.checkpoint) } : {}), }); changed = true; } @@ -1129,10 +1146,10 @@ export class ArkManager { const past = list.filter((v) => this._pastExpiry(v, tip)).sort(cmp); return [...live, ...past]; } - // largest-first until `need` is covered; the coins still inside their + // largest-first (or in `order`) until `need` is covered; the coins inside their // timelock alone if they can carry it, else everything - _accumulate(spendable, tip, need) { - const desc = (a, b) => b.amountSat - a.amountSat; + _accumulate(spendable, tip, need, order) { + const desc = order || ((a, b) => b.amountSat - a.amountSat); const live = spendable.filter((v) => !this._pastExpiry(v, tip)).sort(desc); for (const pool of [live, [...spendable].sort(desc)]) { const picked = []; @@ -1162,13 +1179,48 @@ export class ArkManager { return picked; } + // Inputs for a Lightning send of `amountSat` plus `routing`, and the fee + // they come to: { inputs, feeSat }. Throws when the coins can't cover it. + _selectLnInputs(spendable, tip, amountSat, routing) { + let inputs = null, feeSat = 0; + // A wallet that renews its own coins pays for every renewal, so it + // spends the coins nearest expiry first (Stacker News, 2026-10-29): the + // ASP takes them, so nobody downstream inherits the short timelock, as + // an Ark-to-Ark send's recipient would. Otherwise (and on a tie) the + // smallest coin that covers it, keeping the balance in few coins. + const oldest = !!this.lnOldestFirst(); + const exp = (v) => v.expiryHeight || Infinity; + const bySize = (a, b) => a.amountSat - b.amountSat; + const byAge = (a, b) => (exp(a) - exp(b)) || bySize(a, b); + for (const v of this._spendOrder(spendable, tip, oldest ? byAge : bySize)) { + const fee = lnSendFee(amountSat, this.info.lnSendFees, [v], tip) + routing; + if (v.amountSat >= amountSat + fee) { inputs = [v]; feeSat = fee; break; } + } + if (!inputs) { + const need = (set, sum) => { + feeSat = lnSendFee(amountSat, this.info.lnSendFees, set, tip) + routing; + return sum >= amountSat + feeSat; + }; + let picked = oldest ? this._accumulate(spendable, tip, need, byAge) : null; + // too many old crumbs for one payment: fall back to largest-first + if (!picked || picked.length > MAX_SEND_INPUTS) picked = this._accumulate(spendable, tip, need); + if (picked) inputs = picked; + if (!inputs) throw new Error(this._insufficientMsg(tip)); + if (inputs.length > MAX_SEND_INPUTS) throw new Error('balance is spread over too many coins for one payment'); + } + return { inputs, feeSat }; + } + _insufficientMsg(tip) { const expiredSat = this.state.vtxos .filter((v) => v.state === 'spendable' && this._expired(v, tip)) .reduce((n, v) => n + v.amountSat, 0); if (expiredSat) return `insufficient ark balance (${expiredSat} sat expired and unusable)`; const cappedSat = this.state.vtxos.filter((v) => this._free(v) && this._capped(v)).reduce((n, v) => n + v.amountSat, 0); - if (cappedSat) return `insufficient ark balance (${cappedSat} sat have a history too long to spend and are waiting to be renewed)`; + // under dust a round won't take it either: only new money pays from here + if (cappedSat) return cappedSat < 330 + ? `insufficient ark balance (${cappedSat} sat have a history too long to spend and are too few to renew; receive more to keep paying)` + : `insufficient ark balance (${cappedSat} sat have a history too long to spend and are waiting to be renewed)`; // coins a renewal took stay unspendable until its round confirms on-chain const renewing = lnShortfall(this.state.vtxos, this.state.actions, Infinity).renewingSat; return renewing @@ -1496,9 +1548,13 @@ export class ArkManager { 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 prior = this.state.actions.find((a) => + a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step !== 'failed'); + if (prior) { + // two messages, so a caller can tell a settled payment from one still + // on its way (an NWC client once read "already paid or is being paid" + // as paid, hours before the money moved) + throw new Error(prior.step === 'done' ? 'this invoice was already paid' : 'a payment for this invoice is already in progress'); } const tip = await this.chain.tipHeight(); const routing = routingFeeSat != null ? routingFeeSat : await this.lnRouteFee(invoice, amountSat); @@ -1506,22 +1562,10 @@ export class ArkManager { // 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 busy = this._remoteBusy(); + // a coin at the depth cap can't be an input: the ASP refuses the cosign const spendable = this.state.vtxos - .filter((v) => this._free(v, busy) && !this._expired(v, tip)); - let inputs = null, feeSat = 0; - for (const v of this._spendOrder(spendable, tip, (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 = this._accumulate(spendable, tip, (set, sum) => { - feeSat = lnSendFee(amountSat, this.info.lnSendFees, set, tip) + routing; - return sum >= amountSat + feeSat; - }); - if (picked) inputs = picked; - if (!inputs) throw new Error(this._insufficientMsg(tip)); - if (inputs.length > MAX_SEND_INPUTS) throw new Error('balance is spread over too many coins for one payment'); - } + .filter((v) => this._free(v, busy) && !this._expired(v, tip) && !this._capped(v)); + const { inputs, feeSat } = this._selectLnInputs(spendable, tip, amountSat, routing); // per-input HTLC share: first n-1 consumed whole, the last carries change let remainingLock = amountSat + feeSat; const parts = inputs.map((v) => { @@ -1678,9 +1722,16 @@ export class ArkManager { action.step = 'initiated'; this._save(); } catch (e) { + // the HTLC already went to this payment: another context (a second + // wake of the phone's worker, seconds apart, over its own copy of the + // state) initiated it first. That payment is this one — follow it. + // (PullCap 2026-10-24 23:21: a paid send was marked 'revoking' here.) + if (e instanceof GrpcError && /state: ln-spent/i.test(e.message)) { + action.step = 'initiated'; + this._save(); // INVALID_ARGUMENT: the server itself deems the invoice unpayable — // retrying can never succeed, revoke the HTLC instead - if (e instanceof GrpcError && e.grpcStatus === 3) { + } else if (e instanceof GrpcError && e.grpcStatus === 3) { action.error = e.message; action.step = 'revoking'; this._save(); @@ -1707,7 +1758,22 @@ export class ArkManager { this._save(); } } - if (action.step === 'revoking') await this._revokeLnPay(action); + if (action.step === 'revoking') { + // Ask first: a send can land in 'revoking' over a stale copy of the + // state while the payment went through (or is still going) — a + // settled payment is settled here too, and one in flight waits. + const res = await checkLightningPayment(this.arkUrl, hex.decode(action.paymentHash), false).catch(() => null); + if (res && res.status === 'success' && res.preimage + && hex.encode(sha256(hex.decode(res.preimage))) === action.paymentHash) { + this._settleLnPay(action, res.preimage); + return; + } + if (res && res.status === 'pending' && action.htlcExpiry) { + const tip = await this.chain.tipHeight().catch(() => 0); + if (tip && tip <= action.htlcExpiry) { action.step = 'initiated'; delete action.error; this._save(); return; } + } + await this._revokeLnPay(action); + } } _settleLnPay(action, preimageHex) { @@ -2671,8 +2737,11 @@ export class ArkManager { if (!tip) return 0; const covered = new Set((this.state.scheduled || []).flatMap((s) => s.inputIds)); const cap = this.info.maxVtxoAmountSat || Infinity; + // a round output under dust (330 sat) is refused, so a coin that small + // can't be renewed on its own — asking only spends the wake's budget const coins = deep - .filter((v) => !covered.has(v.id) && v.expiryHeight > tip + 2 && v.amountSat <= cap) + .filter((v) => !covered.has(v.id) && v.expiryHeight > tip + 2 && v.amountSat <= cap + && v.amountSat - this.refreshFee([v], tip + 2) >= 330) .sort((a, b) => this.exitDepth(b) - this.exitDepth(a)) .slice(0, maxCoins); let n = 0; |
