aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-29 09:52:21 -0700
committerAdam Soltys <[email protected]>2026-10-29 09:52:21 -0700
commit0677a8b3357ffb12537081df919a7251bf63e3d4 (patch)
treefa1eb73101187580e58b7dadd871f326673118a5 /src
parent01b6c11555bb9a131dbcabe897327a9f928ccf1b (diff)
downloadcoinosv3-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
Diffstat (limited to 'src')
-rw-r--r--src/ark/manager.js13
-rw-r--r--src/nwc-respond.js21
2 files changed, 27 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;
};