diff options
| -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 |
