diff options
| author | Adam Soltys <[email protected]> | 2026-10-29 09:52:21 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-29 09:52:21 -0700 |
| commit | 0677a8b3357ffb12537081df919a7251bf63e3d4 (patch) | |
| tree | fa1eb73101187580e58b7dadd871f326673118a5 | |
| parent | 01b6c11555bb9a131dbcabe897327a9f928ccf1b (diff) | |
| download | coinosv3-0677a8b3357ffb12537081df919a7251bf63e3d4.tar.gz coinosv3-0677a8b3357ffb12537081df919a7251bf63e3d4.zip | |
A closed wallet whose payment fails now says so: the error (INSUFFICIENT_BALANCE or PAYMENT_FAILED, with the reason) is left with the notifier at once and goes out unless someone answers within 5 s. Waiting the grace period out in the worker ran past iOS's ten seconds after a failed send, so the other app heard nothing (PullCap, 2026-10-29). A Lightning send no longer picks a coin at the server's depth cap, so such a coin fails here with a reason instead of at the ASP; a capped coin under 330 sats (a round won't take it) says to receive more instead of "waiting to be renewed", and the worker stops booking renewals the server would refuse as dust
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VMan6TUeqTyZB8eXLGymUY
| -rw-r--r-- | src/ark/manager.js | 13 | ||||
| -rw-r--r-- | src/nwc-respond.js | 21 | ||||
| -rw-r--r-- | tools/nwc-sw-test.js | 23 |
3 files changed, 50 insertions, 7 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index aea6020..853ca84 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -1182,7 +1182,10 @@ export class ArkManager { .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 @@ -1524,8 +1527,9 @@ 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)); + .filter((v) => this._free(v, busy) && !this._expired(v, tip) && !this._capped(v)); 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; @@ -2711,8 +2715,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; diff --git a/src/nwc-respond.js b/src/nwc-respond.js index 6ea23ab..04340d0 100644 --- a/src/nwc-respond.js +++ b/src/nwc-respond.js @@ -30,6 +30,7 @@ const MAX_AGE_SEC = 120; // subscription; with an error it can tell the payer their wallet is waiting // for them). The notifier holds the reply this long before sending it. const DECLINE_AFTER_MS = 20_000; +const FAIL_AFTER_MS = 5_000; // the notifier's shortest hold const WAIT_MSG = 'Coinos is waiting for its owner to open the app and approve. Look this request up again later.'; const PAY_WAIT_MS = 20_000; // inside the SW's ~30s budget, with reply margin const nowSec = () => Math.floor(Date.now() / 1000); @@ -474,13 +475,25 @@ export async function respondFromBg(data, { // the manager's start (the ASP's info, the chain tip) runs while the // notifier is asked whether anyone else answered ready().catch(() => {}); - // A local failure carries no urgency — wait out the grace period and - // re-check before publishing, so a sibling device's slower SUCCESS reply - // wins the client's eyes instead of being shouted over. + // A local failure carries no urgency — a sibling device's slower SUCCESS + // reply must win the client's eyes instead of being shouted over. The + // error is left with the notifier, which sends it only if nobody answers + // in FAIL_AFTER_MS: waiting here instead ran past iOS's ten seconds after + // a failed send, and the other app heard nothing at all (PullCap, + // 2026-10-29). Waiting here is the fallback when the notifier won't take it. const failQuietly = async (message) => { + const code = /insufficient/i.test(message || '') ? 'INSUFFICIENT_BALANCE' : 'PAYMENT_FAILED'; + try { + const evt = await buildReply(conn, ev, scheme, errRes('pay_invoice', code, message)); + const r = await fetchFn(`${notifier}/decline`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ event: evt, afterMs: FAIL_AFTER_MS }), + }); + if (r.ok) { log(`payment failed (${message}) — ${code} goes out unless someone answers first`); return true; } + } catch {} await new Promise((r) => setTimeout(r, errGraceMs)); if (!(await answered())) { - await publish(errRes('pay_invoice', 'INTERNAL', message)).catch(() => {}); + await publish(errRes('pay_invoice', code, message)).catch(() => {}); } return true; }; diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js index 376a12e..7e85ee1 100644 --- a/tools/nwc-sw-test.js +++ b/tools/nwc-sw-test.js @@ -317,6 +317,29 @@ console.log('\n[sends begun on an earlier wake]'); } } +console.log('\n[a send that fails]'); +// The ASP refused the cosign (a coin at the depth cap, PullCap 2026-10-29): +// the error is left with the notifier at once, not waited out in a worker +// iOS stops before it speaks. +{ + const { ArkManager } = await import('../src/ark/manager.js'); + const names = ['init', 'payLnInvoice']; + const orig = Object.fromEntries(names.map((k) => [k, ArkManager.prototype[k]])); + ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; }; + try { + for (const [why, code] of [ + ['insufficient ark balance (259 sat have a history too long to spend and are too few to renew; receive more to keep paying)', 'INSUFFICIENT_BALANCE'], + ['ln-pay failed: route not found', 'PAYMENT_FAILED'], + ]) { + ArkManager.prototype.payLnInvoice = async function () { throw new Error(why); }; + const { deps, published, declined, decryptReply } = harness(); + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, { ...deps, errGraceMs: 60_000 }); + const r = declined.length ? decryptReply(declined[0]) : null; + check(`${code}: the error is handed to the notifier, nothing waited out here`, handled === true && declined.length === 1 && !published.length && r.error?.code === code && r.error.message === why, JSON.stringify(r)); + } + } finally { Object.assign(ArkManager.prototype, orig); } +} + console.log('\n[network fit]'); { // a mutinynet-era mirror must not answer a mainnet invoice with a nonsense |
