aboutsummaryrefslogtreecommitdiffstats
path: root/tools/nwc-sw-test.js
blob: 1ce9944cb17938466ff66760d107baf9a817c562 (plain) (blame)
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
// 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);