aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
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;
};