aboutsummaryrefslogtreecommitdiffstats
path: root/src/nwc-bg.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-24 07:54:32 -0700
committerAdam Soltys <[email protected]>2026-10-24 07:54:32 -0700
commiteec7abcfa329b6b405e69ffff54ae0a312f66860 (patch)
tree69e563bb6bd61ed0a53406f632281778c5d2f823 /src/nwc-bg.js
parentce96364662451556e9ae6fe8222eba4ea23bd27c (diff)
downloadcoinosv3-eec7abcfa329b6b405e69ffff54ae0a312f66860.tar.gz
coinosv3-eec7abcfa329b6b405e69ffff54ae0a312f66860.zip
A closed wallet finishes the Lightning sends an earlier wake left in flight, instead of calling the invoice "already paid" with no preimage and locking its coin until the app opens
PullCap's night of 2026-10-23 (hourly 1-sat NWC requests to a closed iPhone PWA): three of fifteen hours went wrong the same way. The push woke the worker, it reserved the coin and wrote the ln-pay action to the mirror, and the phone stopped it before the ASP was ever asked (no cosign in captaind's log for those minutes). The client's retry ten minutes later hit the manager's duplicate check, "this invoice was already paid or is being paid", published as an INTERNAL error that the client read as paid — hours before the money moved. And since the stuck action held the wallet's only coin, the next hour's request (a different invoice) found nothing spendable and fell to "Open Coinos to approve". Everything settled at once when the app opened at 07:12 and resumed the actions. - nwc-respond.js: pay_invoice first looks for a send already on the books for this invoice — finishing one in flight and answering with its preimage, repeating the preimage of one already done (nothing new on the spend log) — and before paying a new invoice drives any other stuck send to completion so its coin comes back. tendLnPays() does the same on every wake, lookups included, and hands back "Payment sent" lines for what settled. rec.pendingPays remembers which connection a send belongs to (the action is the manager's); the app's mirror rewrite carries it. - manager.js: the duplicate check says "already paid" only for a settled send, "already in progress" for one on its way. - features/nwc.js: the open app answers a repeated invoice from the local action (preimage when settled, an honest "still in progress" otherwise) before asking the ASP about a sibling device. - sw-nwc.js: one push at a time over the shared mirror (Web Lock), so a client's lookup + retry seconds apart can't double-drive one send. A diary of what the worker did — each push, each ln-pay step as the mirror saves it, every notification shown or refused, the fallback taken — is appended line by line to IndexedDB (nwc-bg.js appendBgLog), so a worker the phone stops mid-push leaves its last step behind. Settings → Notifications → "Background activity log" copies it. - tests: nwc-sw-test covers the four resumed-send cases; sw-push-quota mocks the new seams and its "suspended app" case now expects the stand-in-first order shipped in 91dbee4 (it had been failing since). Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'src/nwc-bg.js')
-rw-r--r--src/nwc-bg.js29
1 files changed, 27 insertions, 2 deletions
diff --git a/src/nwc-bg.js b/src/nwc-bg.js
index 3349126..2c73206 100644
--- a/src/nwc-bg.js
+++ b/src/nwc-bg.js
@@ -57,6 +57,30 @@ export async function saveBg(walletKey, rec) {
export async function clearBg(walletKey) {
return tx('readwrite', (s) => { s.delete(walletKey); });
}
+
+// The worker's diary: what it did while the app was closed — each push, the
+// steps of a payment, every notification shown or refused. A phone stops a
+// worker without a trace, and the diary's last line says where it got to.
+// Settings → Notifications → Copy hands it to whoever is looking into it.
+// Appended line by line (a worker that dies mid-push keeps what it wrote),
+// one writer at a time, a few hundred lines kept.
+const LOG_KEY = '__log';
+const LOG_MAX = 400;
+let logChain = Promise.resolve();
+export function appendBgLog(line) {
+ logChain = logChain.then(() => tx('readwrite', async (s) => {
+ const cur = (await get(s, LOG_KEY)) || [];
+ cur.push(line);
+ s.put(cur.length > LOG_MAX ? cur.slice(-LOG_MAX) : cur, LOG_KEY);
+ })).catch(() => {});
+ return logChain;
+}
+export async function readBgLog() {
+ try { return (await tx('readonly', (s) => get(s, LOG_KEY))) || []; } catch { return []; }
+}
+export async function clearBgLog() {
+ return tx('readwrite', (s) => { s.delete(LOG_KEY); });
+}
// The service worker doesn't know wallet cache keys — it finds the right
// record by the connection's service pubkey across all of them.
export async function allBgs() {
@@ -67,7 +91,8 @@ export async function allBgs() {
cur.onsuccess = () => {
const c = cur.result;
if (!c) return res(out);
- out.push({ walletKey: c.key, rec: c.value });
+ // the worker's diary shares the store under a reserved key
+ if (!(typeof c.key === 'string' && c.key.startsWith('__'))) out.push({ walletKey: c.key, rec: c.value });
c.continue();
};
cur.onerror = () => rej(cur.error);
@@ -119,7 +144,7 @@ export function buildBg({ ark, mgrState, keyFor, connections, offer, autowithdra
// worker counts what the app spent; a revoked one rides along flagged, to
// be refused rather than ignored.
connections: (connections || []).map((c) => ({
- id: c.id, servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk,
+ id: c.id, name: c.name || '', servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk,
maxSat: c.maxSat, dailySat: c.dailySat, period: c.period || 'day',
spentDate: c.spentDate || null, spentToday: c.spentToday || 0,
revoked: !!c.revoked,