diff options
Diffstat (limited to 'src/ark/manager.js')
| -rw-r--r-- | src/ark/manager.js | 50 |
1 files changed, 45 insertions, 5 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index caece44..aea6020 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); @@ -805,9 +815,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; } @@ -1496,9 +1510,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); @@ -1678,9 +1696,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 +1732,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) { |
