aboutsummaryrefslogtreecommitdiffstats
path: root/src/nwc-respond.js
diff options
context:
space:
mode:
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