1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
|
// NWC auto-answer while the app is closed. Runs inside the service worker's
// push handler: the push payload carries the encrypted request event, the
// background record (IndexedDB) carries the connection service keys and a
// mirror of the wallet's ark state, and the reply goes back through the
// notifier's /publish endpoint — no WebSocket, no seed.
//
// Trust: the notifier transports ciphertext both ways. Requests arrive
// nip04/nip44-encrypted and signed by the client; replies leave encrypted and
// signed by the per-connection service key. The notifier can neither read nor
// forge either. Payments run the ASP's own Lightning send directly over
// fetch — the same path the open app uses.
//
// Anything this module can't handle returns false, and the caller falls back
// to a notification that opens the wallet.
import { hex } from '@scure/base';
import * as nip04 from 'nostr-tools/nip04';
import * as nip44 from 'nostr-tools/nip44';
import { finalizeEvent } from 'nostr-tools/pure';
import { ArkManager } from './ark/manager.js';
import { maybeBolt11 } from './ark/lightning.js';
import { allBgs, saveBg } from './nwc-bg.js';
import { budgetRenewsAt, renewalPeriod, spentInWindow } from './nwc-budget.js';
import { renewPolicy } from './ark/manager.js';
const MAX_AGE_SEC = 120;
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 } });
const decrypt = async (scheme, skHex, pk, ct) => (scheme === 'nip44_v2'
? nip44.decrypt(ct, nip44.getConversationKey(hex.decode(skHex), pk))
: Promise.resolve(nip04.decrypt(hex.decode(skHex), pk, ct)));
async function buildReply(conn, ev, scheme, payload) {
const sk = hex.decode(conn.serviceSk);
const content = scheme === 'nip44_v2'
? nip44.encrypt(JSON.stringify(payload), nip44.getConversationKey(sk, ev.pubkey))
: await Promise.resolve(nip04.encrypt(sk, ev.pubkey, JSON.stringify(payload)));
return finalizeEvent({
kind: 23195, created_at: nowSec(),
tags: [['p', ev.pubkey], ['e', ev.id], ['encryption', scheme]],
content,
}, sk);
}
const spendableSat = (rec) =>
((rec.mgr && rec.mgr.vtxos) || []).filter((v) => v.state === 'spendable')
.reduce((n, v) => n + (v.amountSat || 0), 0);
// The worker can't derive keys — the mirror carries them per chain:
// 3 = coins/change, 4/0 = mailbox, 5 = receive preimages.
export function shimAccount(rec) {
const byChain = (chain, i) => {
const k = chain === 5 ? rec.keys5?.[String(i)]
: chain === 4 ? rec.key4
: rec.keys[String(i)];
if (!k) throw new Error(`chain ${chain} index ${i} outside the mirrored window`);
return hex.decode(k);
};
return { deriveChild: (chain) => ({ deriveChild: (i) => ({ privateKey: byChain(chain, i) }) }) };
}
export async function bgManager(rec, walletKey, saveFn, log = null) {
// the diary follows each send's steps through the mirror's saves: a worker
// the phone stops mid-payment leaves its last step on record
const steps = new Map(((rec.mgr && rec.mgr.actions) || []).filter((a) => a.type === 'ln-pay').map((a) => [a.id, a.step]));
const storage = {
load: () => rec.mgr,
save: (s) => {
rec.mgr = s;
if (log) {
for (const a of s.actions || []) {
if (a.type !== 'ln-pay' || steps.get(a.id) === a.step) continue;
steps.set(a.id, a.step);
log(`ln-pay ${String(a.paymentHash || '').slice(0, 8)} → ${a.step}${a.error ? ': ' + a.error : ''}`);
}
}
Promise.resolve(saveFn(walletKey, rec)).catch(() => {});
},
};
return new ArkManager({
account: shimAccount(rec), storage,
arkUrl: rec.ark.arkUrl, esploraUrl: rec.ark.esploraUrl, network: rec.ark.network,
honorsExpired: !!rec.ark.honorsExpired,
}).init();
}
// ---- sends in flight across wakes ------------------------------------------
// The phone stops a worker without warning: between reserving a coin and the
// ASP's cosign, or between the ASP paying and our reply. The action stays in
// the mirror at its last step, its coin reserved, and only the app used to
// finish it. These let the worker finish it on a later wake instead.
const pendingLnPays = (rec) => ((rec.mgr && rec.mgr.actions) || [])
.filter((a) => a.type === 'ln-pay' && !['done', 'failed'].includes(a.step));
// which connection a send belongs to, kept beside the mirror: the action is
// the manager's and knows nothing of NWC
function notePending(rec, hash, conn) {
rec.pendingPays = (rec.pendingPays || []).filter((p) => p.hash !== hash).slice(-50);
rec.pendingPays.push({ hash, connId: conn.id, ts: Date.now() });
}
function forgetPending(rec, hash) {
if (rec.pendingPays) rec.pendingPays = rec.pendingPays.filter((p) => p.hash !== hash);
}
function connOf(rec, a) {
const p = (rec.pendingPays || []).find((x) => x.hash === a.paymentHash);
return (p && (rec.connections || []).find((c) => c.id === p.connId)) || null;
}
// A settled send goes on the worker's spend log once; the app absorbs it
// into budgets and history on next open.
function recordBgSpend(rec, a, conn, fallback = {}) {
const hash = a.paymentHash || fallback.paymentHash;
forgetPending(rec, hash);
rec.spends = rec.spends || [];
if (hash && rec.spends.some((s) => s.paymentHash === hash)) return;
rec.spends.push({
connId: conn ? conn.id : null, amountSat: a.amountSat || fallback.amountSat, feeSat: a.feeSat || 0,
paymentHash: hash, preimage: a.preimage, invoice: a.invoice || fallback.invoice, ts: Date.now(),
});
if (rec.spends.length > 200) rec.spends = rec.spends.slice(-200);
}
// Money left while the app was closed — that deserves a heads-up, unless
// the user turned "payments sent" notifications off in Settings.
function sentNotice(rec, a, conn, fallbackSat = 0) {
if (rec.prefs && rec.prefs.paySent === false) return true;
const paidSat = a.amountSat || fallbackSat;
const feeNote = a.feeSat ? ` (+${a.feeSat} fee)` : '';
return {
notify: {
title: 'Payment sent', tag: 'nwc-sent',
body: `-${paidSat.toLocaleString()} sats${feeNote} via ${(conn && conn.name) || 'a connected app'}`,
},
};
}
// Drive a Lightning send on until it settles, fails, or the budget is gone.
// Every step is held to the deadline: a call to the ASP that hangs must not
// carry the worker past its budget (iOS stops it, mid-line, with no trace).
async function finishLnPay(mgr, id, deadline) {
const by = (p) => Promise.race([p, new Promise((r) => setTimeout(r, Math.max(0, deadline - Date.now())))]);
let a = await by(mgr.awaitLnPay(id, Math.max(500, deadline - Date.now())).catch(() => null));
a = mgr.lnAction(id);
while (a && !['done', 'failed'].includes(a.step) && Date.now() < deadline - 1500) {
await new Promise((r) => setTimeout(r, 1000));
await by(mgr.driveLn(id).catch(() => {}));
a = mgr.lnAction(id);
}
return a;
}
// A CLINK offer request (kind 21001): mint an invoice against the mirrored
// wallet and reply. The payment itself arrives later over the ASP's hold
// invoice — the app claims it on next open, so we also ask the caller to
// raise a "money incoming" notification.
async function respondOffer(ev, rec, walletKey, { notifier, fetchFn, saveFn, log }) {
const offer = rec.offer;
if (!offer || !offer.sk) return false;
const sk = hex.decode(offer.sk);
let req;
try { req = JSON.parse(nip44.decrypt(ev.content, nip44.getConversationKey(sk, ev.pubkey))); } catch { return false; }
const publish = async (payload) => {
const content = nip44.encrypt(JSON.stringify(payload), nip44.getConversationKey(sk, ev.pubkey));
const evt = finalizeEvent({
kind: 21001, created_at: nowSec(),
tags: [['p', ev.pubkey], ['e', ev.id], ['clink_version', '1']],
content,
}, sk);
const r = await fetchFn(`${notifier}/publish`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ event: evt }),
});
if (!r.ok) throw new Error(`publish refused: ${r.status}`);
};
try {
const answered = await fetchFn(`${notifier}/answered?event=${ev.id}`).then((r) => r.json()).catch(() => ({}));
if (answered.answered) return true;
const sat = Math.floor(req.amount_sats || 0);
if (!sat || sat < 1) { await publish({ error: 'Invalid Amount', code: 5, range: { min: 1, max: 1000000 } }); return true; }
if (!rec.keys5 || !rec.key4) return false; // old mirror: wake the user
if (!rec.keys5[String(rec.mgr.nextLnRecvIndex || 0)]) return false; // preimage window exhausted
const mgr = await bgManager(rec, walletKey, saveFn);
const a = await mgr.createLnInvoice(sat, (req.description || '').slice(0, 100));
// pending zap receipts are published by the app when it claims — stash
// what it needs alongside the mirror
if (req.zap) {
rec.zapPending = rec.zapPending || [];
rec.zapPending.push({ zap: req.zap, invoice: a.invoice, ts: Date.now() });
if (rec.zapPending.length > 50) rec.zapPending = rec.zapPending.slice(-50);
}
await saveFn(walletKey, rec);
await publish({ bolt11: a.invoice });
log(`minted ${sat} sat offer invoice while closed`);
return { notify: { title: 'Incoming payment', body: 'Someone is paying you. Open Coinos to receive it.' } };
} catch (e) {
log('offer mint failed: ' + e.message);
await publish({ error: 'Temporary Failure', code: 2 }).catch(() => {});
return true;
}
}
// data: the push payload { type:'nwc', servicePubkey, event }. Returns true
// when the request was fully dealt with (reply published or deliberately
// left to another device); false means "wake the user instead".
export async function respondFromBg(data, {
notifier, fetchFn = fetch, recordsFn = allBgs, saveFn = saveBg, log = () => {},
errGraceMs = 3000, deadline: until = null,
} = {}) {
const ev = data && data.event;
if (!ev || !ev.id || !ev.pubkey) return false;
if (nowSec() - (ev.created_at || 0) > MAX_AGE_SEC) return false;
const target = (ev.tags?.find((t) => t[0] === 'p') || [])[1];
if (!target) return false;
// CLINK offer requests route by the offer service pubkey — freshest record
// wins if stale copies linger.
if (ev.kind === 21001) {
const offers = (await recordsFn())
.filter((r) => r.rec.v >= 3 && r.rec.offer?.pk === target)
.sort((a, b) => (b.rec.updated || 0) - (a.rec.updated || 0));
if (!offers.length) return false;
return respondOffer(ev, offers[0].rec, offers[0].walletKey, { notifier, fetchFn, saveFn, log });
}
if (ev.kind !== 23194) return false;
// The same connection can survive under several wallet records (an old
// network's mirror beside the live one — they share the service keys).
// Gather every copy, freshest first; pay_invoice narrows further to the
// record whose wallet is on the invoice's network.
const matches = [];
for (const r of await recordsFn()) {
const c = (r.rec.connections || []).find((x) => x.servicePk === target);
if (c && r.rec.v >= 3) matches.push({ walletKey: r.walletKey, rec: r.rec, conn: c });
}
matches.sort((a, b) => (b.rec.updated || 0) - (a.rec.updated || 0));
if (!matches.length) return false;
let { walletKey, rec, conn } = matches[0];
if (ev.pubkey !== conn.clientPk) return false;
const scheme = ev.tags?.find((x) => x[0] === 'encryption')?.[1] === 'nip44_v2' ? 'nip44_v2' : 'nip04';
const publish = async (payload) => {
const evt = await buildReply(conn, ev, scheme, payload);
const r = await fetchFn(`${notifier}/publish`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ event: evt }),
});
if (!r.ok) throw new Error(`publish refused: ${r.status}`);
return true;
};
let req;
try { req = JSON.parse(await decrypt(scheme, conn.serviceSk, ev.pubkey, ev.content)); } catch { return false; }
const method = req?.method;
const params = req?.params || {};
// 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;
// Revoked, lately: refuse out loud — a silent worker looks like a phone
// that is off, which is the one thing the other app cannot tell apart.
if (conn.revoked) {
await publish(errRes(method || 'error', 'UNAUTHORIZED', 'this connection was revoked'));
log('refused a request on a revoked connection');
return true;
}
// Another device (an open tab elsewhere) may have answered already — a
// paid zap must never be shouted over with a late error, and paying twice
// wastes funds-in-flight. The notifier tracks recent reply e-tags.
const answered = async () => {
try {
const r = await fetchFn(`${notifier}/answered?event=${ev.id}`);
return !!(await r.json()).answered;
} catch { return false; }
};
// ...and hands each pay request to the first device that claims it, so
// two awake devices don't each lock an HTLC for one zap. Unreachable →
// proceed: a lone worker must not go silent over a notifier hiccup.
const claim = async () => {
try {
const r = await fetchFn(`${notifier}/claim`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ event: ev.id, by: `sw-${walletKey}`.slice(0, 64) }),
});
if (!r.ok) return true;
return (await r.json()).ok !== false;
} catch { return true; }
};
if (method === 'get_info') {
await publish({
result_type: 'get_info',
result: {
alias: 'Coinos', color: '#15171a',
network: rec.ark.network === 'mainnet' ? 'mainnet' : rec.ark.network,
block_height: 0, block_hash: '',
methods: ['get_info', 'get_balance', 'get_budget', 'pay_invoice', 'make_invoice', 'lookup_invoice', 'list_transactions'],
},
});
return true;
}
// A merchant (BTCPay) mints an invoice per checkout and polls it until
// paid. Minting works like a CLINK offer; a lookup that finds the payer's
// HTLCs waiting claims them here, so the sale settles with the app closed.
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
let a;
try {
const mgr = await bgManager(rec, walletKey, saveFn);
a = await mgr.createLnInvoice(sat, String(params.description || '').slice(0, 100));
await saveFn(walletKey, rec);
} catch (e) {
await publish(errRes('make_invoice', 'INTERNAL', e.message || 'failed')).catch(() => {});
return true;
}
await publish({
result_type: 'make_invoice',
result: {
type: 'incoming', state: 'pending', invoice: a.invoice, payment_hash: a.paymentHash,
amount: sat * 1000, created_at: nowSec(), description: params.description || '',
expires_at: a.expiresAt ? Math.floor(a.expiresAt / 1000) : undefined,
},
});
log(`minted ${sat} sat invoice while closed`);
return true;
}
if (method === 'lookup_invoice') {
if (!params.payment_hash && !params.invoice) {
await publish(errRes('lookup_invoice', 'OTHER', 'payment_hash or invoice required'));
return true;
}
const q = { paymentHash: params.payment_hash, invoice: params.invoice };
// read straight off the mirror; the ASP is only needed to claim. Every
// copy of the connection is asked (the freshest may be another network's
// mirror: PullCap's lookups of a send in flight came back "not found").
let tx = null;
for (const m of matches) {
if (!m.rec.mgr) continue;
const view = Object.assign(Object.create(ArkManager.prototype), { state: m.rec.mgr, account: shimAccount(m.rec) });
tx = view.lnLookup(q);
if (tx) { ({ walletKey, rec, conn } = m); break; }
}
if (!tx && !rec.mgr) return false;
// 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)
const a = tx && tx.state === 'pending'
&& (rec.mgr.actions || []).find((x) => x.type === (tx.type === 'incoming' ? 'ln-recv' : 'ln-pay') && x.paymentHash === tx.payment_hash
&& !['done', 'failed'].includes(x.step));
if (a) {
try {
const mgr = await bgManager(rec, walletKey, saveFn, log);
if (a.type === 'ln-pay') {
const r = await finishLnPay(mgr, a.id, Math.min(deadline, Date.now() + 6000));
if (r && r.step === 'done') {
const c = connOf(rec, r) || conn;
recordBgSpend(rec, r, c);
log(`finished a ${r.amountSat} sat send from an earlier wake`);
const n = sentNotice(rec, r, c);
if (n !== true) lookupNotice = n.notify;
} else if (r && r.step === 'failed') forgetPending(rec, a.paymentHash);
await saveFn(walletKey, rec);
} else {
await Promise.race([mgr.driveLn(a.id).catch((e) => log('claim: ' + e.message)), new Promise((r) => setTimeout(r, Math.max(0, deadline - Date.now())))]);
}
tx = mgr.lnLookup(q);
if (tx.state === 'settled' && tx.type === 'incoming') log(`received ${tx.amount / 1000} sat while closed`);
} catch (e) { log('ark init failed: ' + e.message); }
}
await publish(tx ? { result_type: 'lookup_invoice', result: tx } : errRes('lookup_invoice', 'NOT_FOUND', 'invoice not found'));
log(`lookup: ${tx ? tx.type + ' ' + tx.state : 'not found'}`);
return lookupNotice ? { notify: lookupNotice } : true;
}
if (method === 'get_balance') {
await publish({ result_type: 'get_balance', result: { balance: spendableSat(rec) * 1000 } });
return true;
}
if (method === 'get_budget') {
const { spent } = spentInWindow(conn, rec.spends || []);
await publish({ result_type: 'get_budget', result: {
used_budget: spent * 1000, total_budget: (conn.dailySat || 0) * 1000,
renews_at: budgetRenewsAt(conn.period || 'day'), renewal_period: renewalPeriod(conn.period),
} });
return true;
}
if (method === 'list_transactions') {
// the mirrored wallet's Lightning history, both ways, as the open app reports it
const txs = ((rec.mgr && rec.mgr.movements) || [])
.filter((m) => (m.type === 'ln-send' || m.type === 'ln-receive') && m.status === 'complete')
.slice(-(params.limit || 20))
.map((m) => ({
type: m.type === 'ln-receive' ? 'incoming' : 'outgoing',
state: 'settled', invoice: m.invoice || '', preimage: m.preimage || '',
amount: (m.amountSat || 0) * 1000, fees_paid: 0,
created_at: Math.floor((m.ts || Date.now()) / 1000),
settled_at: Math.floor((m.ts || Date.now()) / 1000),
description: '',
}));
await publish({ result_type: 'list_transactions', result: { transactions: txs } });
return true;
}
if (method !== 'pay_invoice') {
await publish(errRes(method, 'NOT_IMPLEMENTED', 'not available while the wallet is closed'));
return true;
}
// ---- pay_invoice ------------------------------------------------------
if (await answered()) { log('already answered elsewhere'); return true; }
const invoice = params.invoice;
const dec = invoice && maybeBolt11(invoice);
if (!dec) { await publish(errRes('pay_invoice', 'OTHER', 'not a bolt11 invoice')); return true; }
if (!dec.amountSat) { await publish(errRes('pay_invoice', 'OTHER', 'zero-amount invoices are not supported')); return true; }
// The bolt11 names its network; only a record on that network can pay it.
// A mismatched copy fails in milliseconds with a nonsense error that beats
// a sibling device's real payment to the client — so stand down instead,
// and only wake the user if nobody else answered.
const netOf = (n) => (n === 'mutinynet' ? 'signet' : n);
const fit = matches.find((m) => netOf(m.rec.ark && m.rec.ark.network) === dec.network);
if (!fit) {
log(`no ${dec.network} record for this connection — standing down`);
await new Promise((r) => setTimeout(r, errGraceMs));
return answered();
}
({ walletKey, rec, conn } = fit);
if (dec.amountSat > conn.maxSat) {
await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the ${conn.maxSat} sat per-payment limit`));
return true;
}
// What the app spent this window rides on the mirrored connection; the
// worker's own spends the app has not yet folded in are counted from its log.
const { left } = spentInWindow(conn, rec.spends || []);
if (dec.amountSat > left) {
await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the remaining ${renewalPeriod(conn.period)} budget (${left} sat)`));
return true;
}
// A send this worker began on an earlier wake and never finished (PullCap's
// night of 2026-10-23: three hours of fifteen). Its coin sat reserved in
// the mirror, so a fresh attempt at the SAME invoice was refused as
// "already paid or is being paid" — which the client read as paid, hours
// before the money moved — and one at any OTHER invoice found no spendable
// coin and woke the user. Finish what was begun first: the same invoice
// gets that payment's outcome, another invoice gets the coin back.
const stuck = pendingLnPays(rec);
const same = stuck.find((a) => a.paymentHash === dec.paymentHash)
|| (rec.mgr.actions || []).find((a) => a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step === 'done');
let mgr = null;
let mgrP = null;
const ready = async () => mgr || (mgr = await (mgrP || (mgrP = bgManager(rec, walletKey, saveFn, log))));
// 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.
const failQuietly = async (message) => {
await new Promise((r) => setTimeout(r, errGraceMs));
if (!(await answered())) {
await publish(errRes('pay_invoice', 'INTERNAL', message)).catch(() => {});
}
return true;
};
const settled = async (a, c, { record = true } = {}) => {
if (record) recordBgSpend(rec, a, c, { paymentHash: dec.paymentHash, amountSat: dec.amountSat, invoice });
// the mirror and the reply at once: every second counts on iOS, and the
// "Payment sent" line comes after both
await Promise.all([
saveFn(walletKey, rec),
publish({ result_type: 'pay_invoice', result: { preimage: a.preimage, fees_paid: (a.feeSat || 0) * 1000 } }).then(() => log('reply sent')),
]);
return sentNotice(rec, a, c, dec.amountSat);
};
if (same) {
if (!(await claim())) { log('another device claimed this request — standing down'); return true; }
log(`a send for this invoice is already ${same.step} — finishing it`);
const wasDone = same.step === 'done';
let a = same;
if (!wasDone) {
try { await ready(); } catch (e) { log('ark init failed: ' + e.message); return false; }
a = await finishLnPay(mgr, same.id, deadline);
}
// settled earlier (by the app, perhaps): its preimage again, nothing new
// on the spend log
if (a && a.step === 'done') return settled(a, conn, { record: !wasDone });
if (a && a.step === 'failed') { forgetPending(rec, dec.paymentHash); return failQuietly(a.error || 'payment failed'); }
log('that send is still in flight — waking the user to finish it');
return false;
}
const notices = [];
// Only when they hold the coins this payment needs: otherwise the answer
// comes first and the wake's tending finishes them after.
if (stuck.length && spendableSat(rec) < dec.amountSat) {
try {
await ready();
for (const a of stuck) {
if (Date.now() > deadline - 4000) break;
const r = await finishLnPay(mgr, a.id, Math.min(deadline - 3000, Date.now() + 5000));
if (!r || r.step !== 'done') {
log(`ln-pay ${String(a.paymentHash || '').slice(0, 8)} from an earlier wake is still ${r ? r.step : 'gone'}`);
if (r && r.step === 'failed') forgetPending(rec, a.paymentHash);
continue;
}
const c = connOf(rec, r);
recordBgSpend(rec, r, c);
log(`finished a ${r.amountSat} sat send from an earlier wake`);
const n = sentNotice(rec, r, c);
if (n !== true) notices.push(n.notify);
}
await saveFn(walletKey, rec);
} catch (e) { log('ark init failed: ' + e.message); }
}
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; }
// 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 (!(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
notePending(rec, dec.paymentHash, conn);
await saveFn(walletKey, rec);
log(`paying ${dec.amountSat} sat`);
let a = null;
for (let attempt = 0; ; attempt++) {
let id;
try {
id = await mgr.payLnInvoice(invoice);
} catch (e) {
forgetPending(rec, dec.paymentHash);
return failQuietly(e.message);
}
a = await finishLnPay(mgr, id, deadline);
// A ghost coin (spent elsewhere, this mirror never learned) dies on the
// launchpad in milliseconds. Reconcile against the server — flipping the
// ghost — and retry once from live coins; the budget has barely started.
if (attempt === 0 && a && a.step === 'failed' && /not spendable|already spent/i.test(a.error || '')) {
log('a mirrored coin was already spent — reconciling and retrying');
await mgr.reconcile().catch(() => {});
continue;
}
break;
}
if (a && a.step === 'failed' && /already in progress/i.test(a.error || '')) {
// Another device of this wallet started this same payment first. Its
// outcome is ours: wait on it, and only speak if the payer stays quiet.
log('a sibling device is paying this invoice — waiting on its outcome');
forgetPending(rec, dec.paymentHash);
const out = await mgr.lnOutcome(dec.paymentHash, Math.max(3000, deadline - Date.now()));
if (out.status === 'failed') return failQuietly(a.error);
if (out.status !== 'success') { log('sibling payment still pending — leaving it to them'); return true; }
await new Promise((r) => setTimeout(r, errGraceMs));
if (!(await answered())) {
await publish({ result_type: 'pay_invoice', result: { preimage: out.preimage, fees_paid: 0 } });
}
return true;
}
if (!a || a.step === 'failed') {
forgetPending(rec, dec.paymentHash);
return failQuietly((a && a.error) || 'payment failed');
}
if (a.step !== 'done') {
// still in flight as our budget runs out: the next wake (or the app, on
// open) finishes it — wake the user rather than guess.
log('payment still pending at SW deadline');
return false;
}
log(`paid ${a.amountSat || dec.amountSat} sat via the ASP from the background mirror`);
return withNotices(await settled(a, conn));
}
// Sends begun on an earlier wake and never finished (see pay_invoice): any
// wake drives each on, within its budget, and hands back the "Payment sent"
// lines for the ones that settle. Without this a stuck send waited for the
// app to open, its coin reserved all the while.
export async function tendLnPays({ recordsFn = allBgs, saveFn = saveBg, log = () => {}, deadline = Date.now() + 8000 } = {}) {
const notices = [];
for (const { walletKey, rec } of await recordsFn()) {
if (!rec || rec.v < 3 || !rec.mgr || !rec.ark) continue;
const stuck = pendingLnPays(rec);
if (!stuck.length || Date.now() > deadline - 3000) continue;
try {
const mgr = await Promise.race([bgManager(rec, walletKey, saveFn, log),
new Promise((_, rej) => setTimeout(() => rej(new Error('out of time')), Math.max(0, deadline - Date.now())))]);
for (const a of stuck) {
if (Date.now() > deadline - 3000) break;
const r = await finishLnPay(mgr, a.id, deadline);
if (!r || r.step !== 'done') {
log(`ln-pay ${String(a.paymentHash || '').slice(0, 8)} from an earlier wake is still ${r ? r.step : 'gone'}`);
if (r && r.step === 'failed') forgetPending(rec, a.paymentHash);
continue;
}
const c = connOf(rec, r);
recordBgSpend(rec, r, c);
log(`finished a ${r.amountSat} sat send from an earlier wake`);
const n = sentNotice(rec, r, c);
if (n !== true) notices.push(n.notify);
}
await saveFn(walletKey, rec);
} catch (e) { log('earlier sends: ' + e.message); }
}
return notices;
}
// Renewals while the app is closed. The app registers a standing order with
// the ASP for every coin (a round it runs on its own, margin blocks before
// expiry) — but a coin spent by this worker voids its order, and the change
// left behind has none until the app next opens. A wallet only ever woken by
// NWC requests would lose cover one spend after its last open. So each wake
// tends the mirror's coins the way the app does: claim any order whose round
// has run (the output waits 'unclaimed' until someone forfeits the inputs),
// and register an order for every coin without one. Both need only what the
// mirror carries — the coins' keys and a window of upcoming indices.
// Bounded: the push handler has a budget, and anything left over waits for
// the next wake or the app.
export async function tendRenewals({ recordsFn = allBgs, saveFn = saveBg, log = () => {}, deadline = Date.now() + 8000, margin = null } = {}) {
let n = 0;
for (const { walletKey, rec } of await recordsFn()) {
if (!rec || rec.v < 3 || !rec.mgr || !rec.ark || !rec.key4) continue;
if (Date.now() > deadline - 1500) break;
const timeLeft = () => Math.max(0, deadline - Date.now());
const bounded = (p) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('out of time')), timeLeft()))]);
try {
const mgr = await bounded(bgManager(rec, walletKey, saveFn));
await bounded(mgr._tipMemo());
// orders whose round has run: claim them (the output becomes spendable
// here, and merges into the app's state on its next open). A claim is
// several round trips — the funding tx must confirm, then leaf cosign,
// forfeit, adopt — so one that ran out of time (or confirmations) last
// wake sits as a refresh action in flight: drive it on, every wake.
await bounded(mgr._driveScheduled()).catch((e) => log('renewal claim: ' + e.message));
for (const a of mgr.state.actions || []) {
if (a.type !== 'refresh' || ['done', 'failed'].includes(a.step)) continue;
await bounded(mgr._driveRefresh(a)).catch((e) => log('renewal claim: ' + e.message));
if (a.step === 'done') { n++; log('claimed a renewal while closed'); }
}
// coins without an order: register one, as long as the output key the
// order burns is inside the mirrored window (the worker can't derive).
// Only under a custody tier that renews at all — the default lets coins
// run past their timelock in coinos's hands, by choice, and pays nothing.
const policy = renewPolicy(rec.custody || 'custodial', rec.ark.network);
let room = 0;
while (rec.keys[String((mgr.state.nextKeyIndex || 1) + room)] && room < 4) room++;
if (room && policy) {
const got = await bounded(mgr.ensureScheduledRefreshes(margin ?? policy.margin, room, policy.grid)).catch((e) => { log('renewal order: ' + e.message); return 0; });
if (got) { n += got; room -= got; log(`registered ${got} renewal order(s) while closed`); }
}
// Under every tier: a coin near the server's depth cap must be
// flattened to stay spendable (the base fee is waived for it).
if (room > 0) {
const got = await bounded(mgr.ensureDeepRefreshes(room)).catch((e) => { log('deep refresh order: ' + e.message); return 0; });
if (got) { n += got; log(`registered ${got} deep-coin renewal order(s) while closed`); }
}
await saveFn(walletKey, rec);
} catch (e) { log('renewals: ' + e.message); }
}
return n;
}
|