aboutsummaryrefslogtreecommitdiffstats
path: root/src/nwc-respond.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-28 00:22:33 -0700
committerAdam Soltys <[email protected]>2026-10-28 00:22:33 -0700
commit2302a4f0e25562c4d4977990918b86d4c6baba27 (patch)
tree7c187291cdafbfb50b8124dc3db7156ba258af79 /src/nwc-respond.js
parentf5c07454284a123070a0f0159919fc8c2de2b825 (diff)
downloadcoinosv3-2302a4f0e25562c4d4977990918b86d4c6baba27.tar.gz
coinosv3-2302a4f0e25562c4d4977990918b86d4c6baba27.zip
The background worker's key window is 250 keys (was 25: PullCap's hourly subscription ran out after 19 payments with the app closed, "key window exhausted"), derived once per account and memoized; 60 receive preimages (was 15). When the worker can't act on a request it still wakes the user, and now leaves a "wait, open the app" error with the notifier that goes out only if nobody answers within 20 s, so the other app hears something instead of silence and a sibling's real payment is never shouted over. A request older than its two-minute window (a push that waited at APNs) is ignored quietly instead of showing "Payment request"
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'src/nwc-respond.js')
-rw-r--r--src/nwc-respond.js40
1 files changed, 32 insertions, 8 deletions
diff --git a/src/nwc-respond.js b/src/nwc-respond.js
index 0778813..6ea23ab 100644
--- a/src/nwc-respond.js
+++ b/src/nwc-respond.js
@@ -24,6 +24,13 @@ import { budgetRenewsAt, renewalPeriod, spentInWindow } from './nwc-budget.js';
import { renewPolicy } from './ark/manager.js';
const MAX_AGE_SEC = 120;
+// A request the worker can't act on (keys or coins short in the mirror, an
+// old mirror) wakes the user — and, unless someone answers first, tells the
+// other app so (PullCap read the silence as "no answer" and suspended the
+// 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 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);
const errRes = (t, code, message) => ({ result_type: t, error: { code, message } });
@@ -206,7 +213,10 @@ export async function respondFromBg(data, {
} = {}) {
const ev = data && data.event;
if (!ev || !ev.id || !ev.pubkey) return false;
- if (nowSec() - (ev.created_at || 0) > MAX_AGE_SEC) return false;
+ // Older than the request window (a push that sat at APNs while the phone
+ // was out of reach): the other app has moved on — nothing to answer, and
+ // no "Payment request" to show for it. The wake still tends what's in flight.
+ if (nowSec() - (ev.created_at || 0) > MAX_AGE_SEC) { log(`request ${String(ev.id).slice(0, 8)} is ${nowSec() - ev.created_at} s old — ignored`); return true; }
const target = (ev.tags?.find((t) => t[0] === 'p') || [])[1];
if (!target) return false;
@@ -250,6 +260,20 @@ export async function respondFromBg(data, {
try { req = JSON.parse(await decrypt(scheme, conn.serviceSk, ev.pubkey, ev.content)); } catch { return false; }
const method = req?.method;
const params = req?.params || {};
+ // Wake the user, out loud: the wait reply is left with the notifier, which
+ // sends it only if nobody (a sibling device, the woken user) answers in
+ // time — so it can never land on top of a real payment.
+ const declineLater = async (why) => {
+ log(why + ' — waking the user; the app is told to wait');
+ try {
+ const evt = await buildReply(conn, ev, scheme, errRes(method || 'error', 'OTHER', WAIT_MSG));
+ await fetchFn(`${notifier}/decline`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ event: evt, afterMs: DECLINE_AFTER_MS }),
+ });
+ } catch (e) { log('could not leave the wait reply: ' + (e && e.message)); }
+ return false;
+ };
// the push's budget (on iOS about ten seconds from the push, not thirty)
const deadline = until || Date.now() + PAY_WAIT_MS;
let lookupNotice = null;
@@ -301,9 +325,9 @@ export async function respondFromBg(data, {
if (method === 'make_invoice') {
const sat = Math.floor((params.amount || 0) / 1000);
if (!sat) { await publish(errRes('make_invoice', 'OTHER', 'amount required (msat)')); return true; }
- if (!rec.keys5 || !rec.key4 || !rec.mgr) return false; // old mirror: wake the user
- if (!rec.keys5[String(rec.mgr.nextLnRecvIndex || 0)]) return false; // preimage window exhausted
- if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) return false; // claim key window exhausted
+ if (!rec.keys5 || !rec.key4 || !rec.mgr) return declineLater('old mirror, no receive keys');
+ if (!rec.keys5[String(rec.mgr.nextLnRecvIndex || 0)]) return declineLater('preimage window exhausted');
+ if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) return declineLater('key window exhausted');
let a;
try {
const mgr = await bgManager(rec, walletKey, saveFn);
@@ -340,7 +364,7 @@ export async function respondFromBg(data, {
tx = view.lnLookup(q);
if (tx) { ({ walletKey, rec, conn } = m); break; }
}
- if (!tx && !rec.mgr) return false;
+ if (!tx && !rec.mgr) return declineLater('old mirror, no state to look up');
// a payment of ours still on its way (incoming: claim it; outgoing: a
// send an earlier wake began — drive it on, so the lookup itself can
// answer 'settled' with the preimage instead of a second pay request)
@@ -512,13 +536,13 @@ export async function respondFromBg(data, {
const withNotices = (out) => (notices.length && out ? { ...(out === true ? {} : out), notices } : out);
const spendable = spendableSat(rec);
- if (spendable < dec.amountSat) { log(`${spendable} sat spendable in the mirror, ${dec.amountSat} asked — waking the user`); return false; }
+ if (spendable < dec.amountSat) return declineLater(`${spendable} sat spendable in the mirror, ${dec.amountSat} asked`);
// The ASP's own Lightning send, over the mirrored state. Change and HTLC
// outputs allocate from nextKeyIndex, which must stay inside the key
// window the app pre-derived; if it ran out, wake the user instead.
- if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) { log('key window exhausted'); return false; }
- try { await ready(); } catch (e) { log('ark init failed: ' + e.message); return false; }
+ if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) return declineLater('key window exhausted');
+ try { await ready(); } catch (e) { return declineLater('ark init failed: ' + e.message); }
if (!(await claim())) { log('another device claimed this request — standing down'); return true; }
// whose send this is, for the wake that finishes it should this one not