diff options
Diffstat (limited to 'tools/nwc-sw-test.js')
| -rw-r--r-- | tools/nwc-sw-test.js | 144 |
1 files changed, 144 insertions, 0 deletions
diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js index 62248a4..2d07963 100644 --- a/tools/nwc-sw-test.js +++ b/tools/nwc-sw-test.js @@ -239,5 +239,149 @@ console.log('\n[sibling record hygiene]'); 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 = 'early'; rec7.mgr.nextKeyIndex = 5; rec7.keys = { 1: 'aa', 5: 'bb' }; + await tendRenewals({ recordsFn: async () => [{ walletKey: 'w7', rec: rec7 }], saveFn: async () => {} }); + check('early tier → a week ahead on the daily grid', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['ensure', 1008, 1, 144]]), 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); |
