// The service worker's background responder, headless: request decrypt, balance, // budgets, answered-elsewhere suppression, fallbacks. The bridge/ASP pay path // itself is covered by the manager and bridge tests. Run: bun tools/nwc-sw-test.js import { hex } from '@scure/base'; import * as nip04 from 'nostr-tools/nip04'; import * as nip44 from 'nostr-tools/nip44'; import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; import { respondFromBg } from '../src/nwc-respond.js'; let fails = 0; const check = (name, ok, detail = '') => { console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); if (!ok) fails++; }; const serviceSk = generateSecretKey(); const clientSk = generateSecretKey(); const conn = { id: 'c1', servicePk: getPublicKey(serviceSk), serviceSk: hex.encode(serviceSk), clientPk: getPublicKey(clientSk), maxSat: 1000, dailySat: 2000, }; const baseRec = () => ({ v: 3, ark: { arkUrl: 'http://ark.test', esploraUrl: 'http://esp.test', network: 'mainnet', serverPubkey: 'ab'.repeat(32) }, mgr: { v: 1, serverPubkey: 'ab'.repeat(32), nextKeyIndex: 5, vtxos: [{ id: 'v1', bytes: '00', keyIndex: 1, amountSat: 5000, expiryHeight: 1, state: 'spendable' }], actions: [], movements: [], }, keys: { 1: hex.encode(generateSecretKey()), 5: hex.encode(generateSecretKey()) }, connections: [conn], spends: [], }); function makeReq(method, params = {}, scheme = 'nip44_v2', ageSec = 0) { const payload = JSON.stringify({ method, params }); const content = scheme === 'nip44_v2' ? nip44.encrypt(payload, nip44.getConversationKey(clientSk, conn.servicePk)) : nip04.encrypt(clientSk, conn.servicePk, payload); return finalizeEvent({ kind: 23194, created_at: Math.floor(Date.now() / 1000) - ageSec, tags: [['p', conn.servicePk], ...(scheme === 'nip44_v2' ? [['encryption', 'nip44_v2']] : [])], content, }, clientSk); } function harness({ rec = baseRec(), recs = null, answered = false } = {}) { const published = []; const fetchFn = async (url, opts) => { if (url.includes('/publish')) { published.push(JSON.parse(opts.body).event); return { ok: true, json: async () => ({ ok: true }) }; } if (url.includes('/claim')) { return { ok: true, json: async () => ({ ok: true }) }; } if (url.includes('/answered')) { return { ok: true, json: async () => ({ answered: typeof answered === 'function' ? answered() : answered }) }; } throw new Error('unexpected fetch ' + url); }; const deps = { notifier: 'http://notif.test', fetchFn, recordsFn: async () => recs || [{ walletKey: 'w1', rec }], saveFn: async () => {}, errGraceMs: 5, }; const decryptReply = (ev) => { const scheme = ev.tags.find((t) => t[0] === 'encryption')?.[1]; return JSON.parse(scheme === 'nip44_v2' ? nip44.decrypt(ev.content, nip44.getConversationKey(clientSk, conn.servicePk)) : nip04.decrypt(clientSk, conn.servicePk, ev.content)); }; return { deps, published, decryptReply }; } const ev = (e) => JSON.parse(JSON.stringify(e)); // strip verified symbol like a push payload would console.log('[balance + info]'); { const { deps, published, decryptReply } = harness(); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('get_balance')) }, deps); check('get_balance handled', handled === true); const r = decryptReply(published[0]); check('pouch balance in msat', r.result?.balance === 5000000, JSON.stringify(r)); } { const { deps, published, decryptReply } = harness(); await respondFromBg({ type: 'nwc', event: ev(makeReq('get_info', {}, 'nip04')) }, deps); const r = decryptReply(published[0]); check('nip04 get_info answered in nip04', r.result?.alias === 'Coinos' && published[0].tags.find((t) => t[0] === 'encryption')[1] === 'nip04'); } console.log('\n[merchant methods (BTCPay)]'); { const { deps, published, decryptReply } = harness(); await respondFromBg({ type: 'nwc', event: ev(makeReq('get_info')) }, deps); const m = decryptReply(published[0]).result?.methods || []; check('get_info has every method BTCPay requires', ['get_info', 'make_invoice', 'lookup_invoice', 'list_transactions'].every((x) => m.includes(x)), JSON.stringify(m)); } { const INV = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0zpe3alqhp5ppg7g8qwpdv34hgpymhw446y37duzwcn388yp3pw05n7tlulyn2scqzysxqrrssrzjqv3dpepm8kfdxrk3sl6wzqdf49s9c0h9ljtjrek6c08r6aejlwcnur0dwyqqvusqqqqqqqlgqqqq86qqjqsp5dc0jrq94ke2f4dzx8c2dwqsc6a65eu56dt2j599l7kxp7q2hs6zq9qxpqysgqdjeft8gkl0uga24e502pvcp5vgsfap3dxuutcpgfaj33fffuqs9psmnrklshp3fg3py7vlnzsea90vj9ahqq5t9xuy67u3pk0sfnheqpn95f2g'; const H = '7e9d690de6b475ef4f9e964b8a5180afc5fb11af12f0056fece6fa1788398f7e'; const rec = baseRec(); const k5 = generateSecretKey(); rec.keys5 = { 0: hex.encode(k5) }; rec.mgr.actions.push({ id: 'lnrecv-1700000000000', type: 'ln-recv', step: 'done', paymentHash: H, preimageIndex: 0, amountSat: 21, invoice: INV }); rec.mgr.movements.push({ type: 'ln-receive', status: 'complete', amountSat: 21, invoice: INV, ts: 1700000000000 }); const { deps, published, decryptReply } = harness({ rec }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('lookup_invoice', { payment_hash: H })) }, deps); const r = decryptReply(published[0]); check('lookup_invoice of a claimed receive → settled, with the preimage, no ASP needed', handled === true && r.result?.state === 'settled' && r.result?.preimage?.length === 64, JSON.stringify(r.result || r.error)); await respondFromBg({ type: 'nwc', event: ev(makeReq('lookup_invoice', { payment_hash: 'ab'.repeat(32) })) }, deps); check('lookup_invoice of an unknown hash → NOT_FOUND', decryptReply(published[1]).error?.code === 'NOT_FOUND'); await respondFromBg({ type: 'nwc', event: ev(makeReq('list_transactions')) }, deps); const txs = decryptReply(published[2]).result?.transactions || []; check('list_transactions includes receives', txs.some((t) => t.type === 'incoming' && t.amount === 21000), JSON.stringify(txs)); } { const { deps } = harness(); // a mirror without receive keys check('make_invoice on an old mirror → wake the user', (await respondFromBg({ type: 'nwc', event: ev(makeReq('make_invoice', { amount: 21000 })) }, deps)) === false); } console.log('\n[guards]'); { const { deps } = harness(); const stale = ev(makeReq('get_balance', {}, 'nip44_v2', 300)); check('stale request refused', (await respondFromBg({ type: 'nwc', event: stale }, deps)) === false); } { const { deps } = harness(); const foreign = ev(finalizeEvent({ kind: 23194, created_at: Math.floor(Date.now() / 1000), tags: [['p', conn.servicePk]], content: 'junk', }, generateSecretKey())); check('foreign signer refused', (await respondFromBg({ type: 'nwc', event: foreign }, deps)) === false); } { const { deps } = harness(); check('no payload event → wake the user', (await respondFromBg({ type: 'nwc', servicePubkey: conn.servicePk }, deps)) === false); } console.log('\n[pay budgets]'); // a real (long-expired) 21-sat invoice — decode only, nothing is paid const INV21 = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0zpe3alqhp5ppg7g8qwpdv34hgpymhw446y37duzwcn388yp3pw05n7tlulyn2scqzysxqrrssrzjqv3dpepm8kfdxrk3sl6wzqdf49s9c0h9ljtjrek6c08r6aejlwcnur0dwyqqvusqqqqqqqlgqqqq86qqjqsp5dc0jrq94ke2f4dzx8c2dwqsc6a65eu56dt2j599l7kxp7q2hs6zq9qxpqysgqdjeft8gkl0uga24e502pvcp5vgsfap3dxuutcpgfaj33fffuqs9psmnrklshp3fg3py7vlnzsea90vj9ahqq5t9xuy67u3pk0sfnheqpn95f2g'; { const rec = baseRec(); rec.connections = [{ ...conn, maxSat: 10 }]; const { deps, published, decryptReply } = harness({ rec }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); check('over per-payment limit refused', handled && decryptReply(published[0]).error?.code === 'QUOTA_EXCEEDED'); } { const rec = baseRec(); rec.spends = [{ connId: 'c1', amountSat: 1990, feeSat: 0, ts: Date.now() }]; const { deps, published, decryptReply } = harness({ rec }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); check('over daily budget refused', handled && decryptReply(published[0]).error?.code === 'QUOTA_EXCEEDED'); } { const { deps, published } = harness({ answered: true }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); check('answered elsewhere → silent', handled === true && published.length === 0); } { const rec = baseRec(); rec.mgr.vtxos = []; // nothing spendable in the mirror const { deps } = harness({ rec }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); check('empty mirror → wake the user', handled === false); } { const rec = baseRec(); delete rec.keys['5']; // change index outside the mirrored window const { deps } = harness({ rec }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); check('exhausted key window → wake the user', handled === false); } console.log('\n[pay outcome]'); { // a payment that goes through: the worker hands the push handler a // "Payment sent" heads-up (unless the user turned those off) const { ArkManager } = await import('../src/ark/manager.js'); const orig = { init: ArkManager.prototype.init, pay: ArkManager.prototype.payLnInvoice, wait: ArkManager.prototype.awaitLnPay, act: ArkManager.prototype.lnAction }; const done = { id: 'ln-1', step: 'done', amountSat: 21, feeSat: 0, preimage: 'ab'.repeat(32) }; ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; }; ArkManager.prototype.payLnInvoice = async () => 'ln-1'; ArkManager.prototype.awaitLnPay = async () => done; ArkManager.prototype.lnAction = () => done; try { const { deps, published, decryptReply } = harness(); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); check('a paid invoice answers with the preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === done.preimage, JSON.stringify(handled)); check('…and asks for a "Payment sent" notification', handled && handled.notify && handled.notify.title === 'Payment sent', JSON.stringify(handled)); const rec = baseRec(); rec.prefs = { paySent: false }; const h2 = harness({ rec }); const quiet = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, h2.deps); check('payments-sent notifications off → paid quietly', quiet === true && h2.published.length === 1); } finally { Object.assign(ArkManager.prototype, { init: orig.init, payLnInvoice: orig.pay, awaitLnPay: orig.wait, lnAction: orig.act }); } } console.log('\n[network fit]'); { // a mutinynet-era mirror must not answer a mainnet invoice with a nonsense // error — it stands down (false → wake) and publishes nothing const rec = baseRec(); rec.ark.network = 'mutinynet'; const { deps, published } = harness({ rec }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); check('wrong-network record stands down', handled === false && published.length === 0); } { // ...and stays silent when a sibling device already answered const rec = baseRec(); rec.ark.network = 'mutinynet'; const { deps, published } = harness({ rec, answered: true }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); check('wrong-network + answered elsewhere → silent true', handled === true && published.length === 0); } { // two copies of the connection: the fresher mutinynet one must lose to the // older mainnet one for a mainnet invoice. The mainnet copy has an empty // mirror, so reaching it shows as a wake (false) with nothing published — // the mutinynet copy would instead have published a network error. const mut = baseRec(); mut.ark.network = 'mutinynet'; mut.updated = Date.now(); const main = baseRec(); main.updated = Date.now() - 60_000; main.mgr.vtxos = []; const { deps, published } = harness({ recs: [{ walletKey: 'wMut', rec: mut }, { walletKey: 'wMain', rec: main }] }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); check('invoice network picks the fitting record', handled === false && published.length === 0); } { // non-pay methods answer from the freshest copy const stale = baseRec(); stale.updated = 1; stale.mgr.vtxos = []; const fresh = baseRec(); fresh.updated = Date.now(); const { deps, published, decryptReply } = harness({ recs: [{ walletKey: 'wOld', rec: stale }, { walletKey: 'wNew', rec: fresh }] }); await respondFromBg({ type: 'nwc', event: ev(makeReq('get_balance')) }, deps); check('freshest record answers get_balance', decryptReply(published[0]).result?.balance === 5000000); } console.log('\n[sibling record hygiene]'); { const { disarmSiblingRecords } = await import('../src/nwc-bg.js'); const live = baseRec(); const staleShared = baseRec(); // same connection servicePk — must be disarmed const unrelated = baseRec(); unrelated.connections = [{ ...conn, id: 'c2', servicePk: 'f'.repeat(64) }]; const legacy = { v: 2, connections: [conn] }; // pre-v3 pouch: left for its sweep const saved = {}; const n = await disarmSiblingRecords('wLive', live, { loadAll: async () => [ { walletKey: 'wLive', rec: live }, { walletKey: 'wStale', rec: staleShared }, { walletKey: 'wOther', rec: unrelated }, { walletKey: 'wLegacy', rec: legacy }, ], save: async (k, r) => { saved[k] = r; }, }); check('shared connection stripped from the stale record', n === 1 && saved.wStale && saved.wStale.connections.length === 0); check('own, unrelated and legacy records untouched', !saved.wLive && !saved.wOther && !saved.wLegacy); check('stale record keeps its coin state', saved.wStale.mgr.vtxos.length === 1); } console.log('\n[period budgets + get_budget]'); { const { budgetWindow } = await import('../src/nwc-budget.js'); // a monthly budget: the app's counter (mirrored, stamped with this month) plus the worker's own spends count const rec = baseRec(); rec.connections = [{ ...conn, period: 'month', dailySat: 2000, spentDate: budgetWindow('month'), spentToday: 1500 }]; rec.spends = [{ connId: 'c1', amountSat: 490, feeSat: 0, ts: Date.now() }]; const { deps, published, decryptReply } = harness({ rec }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); const r = decryptReply(published[0]); check('app counter + worker spends over the monthly budget → refused, naming the period', handled && r.error?.code === 'QUOTA_EXCEEDED' && /monthly budget \(10 sat\)/.test(r.error.message), JSON.stringify(r.error)); } { const { budgetWindow } = await import('../src/nwc-budget.js'); const rec = baseRec(); // a counter from LAST month and a worker spend from last month: neither counts this month const lastMonth = new Date(); lastMonth.setUTCMonth(lastMonth.getUTCMonth() - 1); rec.connections = [{ ...conn, period: 'month', dailySat: 2000, spentDate: budgetWindow('month', lastMonth.getTime()), spentToday: 1999 }]; rec.spends = [{ connId: 'c1', amountSat: 1999, feeSat: 0, ts: lastMonth.getTime() }]; rec.mgr.vtxos = []; // so the pay stops at "empty mirror" right after the budget check const { deps, published } = harness({ rec }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); check('last month\'s spending does not count this month (budget passed, mirror empty → wake)', handled === false && published.length === 0); } { const { budgetWindow, budgetRenewsAt } = await import('../src/nwc-budget.js'); const rec = baseRec(); rec.connections = [{ ...conn, period: 'week', dailySat: 7000, spentDate: budgetWindow('week'), spentToday: 1000 }]; rec.spends = [{ connId: 'c1', amountSat: 200, feeSat: 1, ts: Date.now() }]; const { deps, published, decryptReply } = harness({ rec }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('get_budget')) }, deps); const r = decryptReply(published[0]); check('get_budget: used, total, renews_at and the period, in msat', handled && r.result?.used_budget === 1201000 && r.result?.total_budget === 7000000 && r.result?.renewal_period === 'weekly' && r.result?.renews_at === budgetRenewsAt('week'), JSON.stringify(r)); const { deps: d2, published: p2, decryptReply: dr2 } = harness(); await respondFromBg({ type: 'nwc', event: ev(makeReq('get_info')) }, d2); check('get_info lists get_budget', dr2(p2[0]).result.methods.includes('get_budget')); } console.log('\n[revoked connection]'); { const rec = baseRec(); rec.connections = [{ ...conn, revoked: true }]; const { deps, published, decryptReply } = harness({ rec }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); const r = decryptReply(published[0]); check('a pay on a revoked connection is refused UNAUTHORIZED, not ignored', handled === true && r.error?.code === 'UNAUTHORIZED' && r.result_type === 'pay_invoice', JSON.stringify(r)); const { deps: d2, published: p2, decryptReply: dr2 } = harness({ rec }); await respondFromBg({ type: 'nwc', event: ev(makeReq('get_balance', {}, 'nip04')) }, d2); check('…so is a balance ask, in the asker\'s scheme', dr2(p2[0]).error?.code === 'UNAUTHORIZED' && p2[0].tags.find((t) => t[0] === 'encryption')[1] === 'nip04'); } console.log('\n[the mirror carries what the worker needs]'); { const { buildBg } = await import('../src/nwc-bg.js'); const keyFor = (chain, i) => `${chain}:${i}`.padEnd(8, '0'); const mgrState = { nextKeyIndex: 40, nextLnRecvIndex: 0, vtxos: [{ id: 'v1', keyIndex: 7, state: 'spendable' }, { id: 'v0', keyIndex: 3, state: 'spent' }], scheduled: [{ unlockHash: 'u1', inputIds: ['v1'], outKeyIndex: 12 }], actions: [{ type: 'refresh', step: 'submitted', outKeyIndex: 15 }, { type: 'refresh', step: 'done', outKeyIndex: 16 }], }; const rec = buildBg({ ark: {}, mgrState, keyFor, connections: [{ ...conn, period: 'month', spentDate: '2026-10', spentToday: 77, revoked: true }] }); check('a standing order\'s output key travels', rec.keys['12'] === keyFor(3, 12)); check('a renewal in flight\'s output key travels', rec.keys['15'] === keyFor(3, 15)); check('a finished renewal\'s does not', !rec.keys['16']); check('the spent coin\'s key does not, the live one\'s does', !rec.keys['3'] && !!rec.keys['7']); const c = rec.connections[0]; check('period, the spent counter and the revocation ride along', c.period === 'month' && c.spentDate === '2026-10' && c.spentToday === 77 && c.revoked === true); check('…and the budget period defaults to a day', buildBg({ ark: {}, mgrState, keyFor, connections: [conn] }).connections[0].period === 'day'); } { const { ArkManager } = await import('../src/ark/manager.js'); const m = ArkManager.mergeScheduled([{ unlockHash: 'a' }, { unlockHash: 'b' }], [{ unlockHash: 'b' }, { unlockHash: 'c' }, { inputIds: [] }]); check('standing orders merge by unlock hash', m.map((s) => s.unlockHash).join() === 'a,b,c', JSON.stringify(m)); } console.log('\n[tending renewals while closed]'); { const { tendRenewals } = await import('../src/nwc-respond.js'); const { ArkManager } = await import('../src/ark/manager.js'); const calls = []; const orig = { init: ArkManager.prototype.init, tip: ArkManager.prototype._tipMemo, drive: ArkManager.prototype._driveScheduled, ensure: ArkManager.prototype.ensureScheduledRefreshes, refresh: ArkManager.prototype._driveRefresh }; ArkManager.prototype.init = async function () { this.state = this.storage.load(); calls.push(['init', this.network]); return this; }; ArkManager.prototype._tipMemo = async function () { calls.push(['tip']); return 100; }; ArkManager.prototype._driveScheduled = async function () { calls.push(['drive', (this.state.scheduled || []).length]); // the round for one order ran: it is promoted — out of scheduled, into a refresh action in flight const ran = (this.state.scheduled || []).find((s) => s.unlockHash === 'ran'); this.state.scheduled = (this.state.scheduled || []).filter((s) => s !== ran); if (ran) (this.state.actions ||= []).push({ id: 'r1', type: 'refresh', step: 'submitted', unlockHash: ran.unlockHash }); }; ArkManager.prototype._driveRefresh = async function (a) { calls.push(['claim', a.id, a.step]); a.step = 'done'; }; ArkManager.prototype.ensureScheduledRefreshes = async function (margin, max, grid) { calls.push(['ensure', margin, max, grid]); return 1; }; try { const rec = baseRec(); rec.key4 = 'ff'.repeat(32); rec.custody = 'self'; // a renewing tier: the worker covers bare coins rec.mgr.nextKeyIndex = 5; rec.keys = { 1: 'aa', 5: 'bb', 6: 'cc' }; // two upcoming indices in the window rec.mgr.scheduled = [{ unlockHash: 'ran', inputIds: ['v1'], outKeyIndex: 2 }, { unlockHash: 'waiting', inputIds: ['v1'], outKeyIndex: 3 }]; const saved = []; const n = await tendRenewals({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async (k, r) => saved.push(k), log: () => {} }); check('claims what ran, then covers what is bare: orders limited to the key window, snapped to the 48-block grid', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 2], ['claim', 'r1', 'submitted'], ['ensure', 36, 2, 48]]), JSON.stringify(calls)); check('one claim and one order counted, the record saved', n === 2 && saved.includes('w1')); check('the claimed order is gone from the mirror, its claim done', rec.mgr.scheduled.length === 1 && rec.mgr.scheduled[0].unlockHash === 'waiting' && rec.mgr.actions[0].step === 'done'); // a claim left in flight by an earlier wake (out of time, unconfirmed) is driven on by the next calls.length = 0; const rec5 = baseRec(); rec5.key4 = 'ff'.repeat(32); rec5.keys = { 1: 'aa' }; rec5.mgr.actions = [{ id: 'r2', type: 'refresh', step: 'issued' }, { id: 'r3', type: 'refresh', step: 'done' }, { id: 's1', type: 'send', step: 'created' }]; const n5 = await tendRenewals({ recordsFn: async () => [{ walletKey: 'w5', rec: rec5 }], saveFn: async () => {} }); check('a renewal claim in flight is driven on; finished ones and other actions are not', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['claim', 'r2', 'issued']]) && n5 === 1, JSON.stringify(calls)); // the default tier — coinos holds the coins — registers no order at all, // however many keys the window holds (the claim of an order that already // ran still happens: it was priced and promised when registered) calls.length = 0; const rec6 = baseRec(); rec6.key4 = 'ff'.repeat(32); rec6.mgr.nextKeyIndex = 5; rec6.keys = { 1: 'aa', 5: 'bb', 6: 'cc' }; await tendRenewals({ recordsFn: async () => [{ walletKey: 'w6', rec: rec6 }], saveFn: async () => {} }); check('custodial (default, or a mirror without the field) → claims only, no orders', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0]]), JSON.stringify(calls)); calls.length = 0; const rec7 = baseRec(); rec7.key4 = 'ff'.repeat(32); rec7.custody = 'exit'; rec7.mgr.nextKeyIndex = 5; rec7.keys = { 1: 'aa', 5: 'bb' }; await tendRenewals({ recordsFn: async () => [{ walletKey: 'w7', rec: rec7 }], saveFn: async () => {} }); check('exit tier → the same backstop as self (depth is the app\'s business)', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['ensure', 36, 1, 48]]), JSON.stringify(calls)); calls.length = 0; const rec2 = baseRec(); rec2.key4 = 'ff'.repeat(32); rec2.custody = 'self'; rec2.ark.network = 'regtest'; rec2.mgr.nextKeyIndex = 9; rec2.keys = { 1: 'aa' }; // no upcoming key await tendRenewals({ recordsFn: async () => [{ walletKey: 'w2', rec: rec2 }], saveFn: async () => {} }); check('no key for an order\'s output → claims still, registers nothing (regtest margin would be 6)', JSON.stringify(calls) === JSON.stringify([['init', 'regtest'], ['tip'], ['drive', 0]]), JSON.stringify(calls)); calls.length = 0; const old = baseRec(); // no key4: a mirror from before invoices — can't sign for the mailbox await tendRenewals({ recordsFn: async () => [{ walletKey: 'w3', rec: old }], saveFn: async () => {} }); check('an old mirror is left alone', calls.length === 0); calls.length = 0; const rec4 = baseRec(); rec4.key4 = 'ff'.repeat(32); await tendRenewals({ recordsFn: async () => [{ walletKey: 'w4', rec: rec4 }], saveFn: async () => {}, deadline: Date.now() - 1 }); check('out of push budget → nothing started', calls.length === 0); } finally { Object.assign(ArkManager.prototype, { init: orig.init, _tipMemo: orig.tip, _driveScheduled: orig.drive, ensureScheduledRefreshes: orig.ensure, _driveRefresh: orig.refresh }); } } console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ background responder behaves'); process.exit(fails ? 1 : 0);