aboutsummaryrefslogtreecommitdiffstats
path: root/tools/sync-outbox-test.js
blob: 02c2020ee3a5f2c2ad44939f09f8fb76d167733c (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
// Run: bun tools/sync-outbox-test.js. No live wallet or network required.
import assert from 'node:assert/strict';
import { SyncOutbox } from '../src/sync-outbox.js';
import { NostrSync } from '../src/nostr.js';
import { installSyncWallet } from '../src/features/sync.js';
import { mergeArkStates, adoptArkSnapshot, arkFeature } from '../src/features/ark.js';
import { createHash } from 'node:crypto';

class Storage {
  data = new Map();
  get length() { return this.data.size; }
  key(i) { return [...this.data.keys()][i] ?? null; }
  getItem(k) { return this.data.get(k) ?? null; }
  setItem(k, v) { this.data.set(k, String(v)); }
  removeItem(k) { this.data.delete(k); }
}
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const signer = (passphrase = '') => { const n = new NostrSync(); n.load(mnemonic, passphrase); return n; };
const a = signer(), b = signer('different wallet');
const storage = new Storage();
globalThis.localStorage = storage;
let now = 1788758000000;
let nextTimer = 0;
const timers = new Map();
const scheduled = [];
const deps = {
  storage, now: () => now,
  schedule: (fn, delay) => { const id = ++nextTimer; timers.set(id, fn); scheduled.push(delay); return id; },
  cancel: id => timers.delete(id), warn: () => {},
};
const enqueue = (box, n, state, dtag = 'bitcoin-wallet:x:test:ark') => box.enqueue({
  pubkey: n.pk, dtag, digest: createHash('sha256').update(JSON.stringify(state)).digest('hex'), relays: ['wss://test.invalid'],
  sign: ts => n.stateEvent(state, dtag, ts),
});
const balance = state => state.vtxos.filter(v => v.state === 'spendable').reduce((n, v) => n + v.amountSat, 0);
const restored = {
  netName: 'mainnet', savedAt: now, arkServer: 'asp', arkState: {
    vtxos: [{ id: 'deposit', amountSat: 5000, state: 'spent' }, { id: 'change', amountSat: 4499, state: 'spendable', bytes: 'signed-coin' }],
    actions: [], movements: [{ id: 'payment', type: 'ln-send', status: 'complete', amountSat: 501, inputIds: ['deposit'] }], nextKeyIndex: 19,
  },
};

// The exact outage: save a payment, relay refuses it, page dies. A fresh
// outbox retries the same signed event and a new device recovers the change.
const offline = new SyncOutbox({ ...deps, send: async () => false });
enqueue(offline, a, restored);
await offline.flush();
assert.equal(offline.records()[0].acknowledged, false);
assert.ok(scheduled.at(-1) >= 5000);
const received = [];
const restarted = new SyncOutbox({ ...deps, send: async event => { received.push(event); return true; } });
await restarted.flush();
assert.equal(restarted.records()[0].acknowledged, true);
assert.equal(balance(a.decodeStateEvents(received)[0].state.arkState), 4499);
assert.equal(a.decodeStateEvents(received)[0].state.arkState.nextKeyIndex, 19);
console.log('✓ rejected publish survives restart and restores change/history');

// Delivery does not need the live signing key, and never switches to the
// account activated while the relay was offline.
enqueue(restarted, b, { netName: 'mainnet', protected: 'private-nwc-connection' });
const bpk = b.pk;
b.unload();
await restarted.flush();
assert.equal(received.at(-1).pubkey, bpk);
assert.equal(a.decodeStateEvents([received.at(-1)]).length, 0);
const raw = [...storage.data.values()].join('');
assert.ok(!raw.includes('private-nwc-connection') && !raw.includes('signed-coin') && !raw.includes(mnemonic));
console.log('✓ queued snapshots stay encrypted and bound to their original account');

// A relay acceptance for the old event must not erase a newer queued update.
now += 2000;
let release;
const racing = new SyncOutbox({ ...deps, send: () => new Promise(r => { release = r; }) });
enqueue(racing, a, { ...restored, savedAt: now });
const inFlight = racing.flush();
enqueue(racing, a, { ...restored, savedAt: now + 1 });
const latestId = racing.events(a.pk)[0].id;
release(true);
await inFlight;
assert.equal(racing.records().find(r => r.event.id === latestId).acknowledged, false);
assert.ok(racing.events(a.pk)[0].created_at > received[0].created_at);
console.log('✓ late acceptance cannot discard a newer snapshot');

// Changes inside a second coalesce until their timestamp is safe to publish.
const future = racing.events(a.pk)[0].created_at;
enqueue(racing, a, { ...restored, savedAt: now + 2 });
assert.equal(racing.events(a.pk)[0].created_at, future);
let calls = 0;
const timed = new SyncOutbox({ ...deps, send: async () => { calls++; return true; } });
await timed.flush();
assert.equal(calls, 0);
now = future * 1000;
await timed.flush();
assert.equal(calls, 1);
console.log('✓ successive updates obey relay replacement ordering');

// Exercise the actual feature save/restore hooks, including stopping realtime
// immediately after a payment and restoring from a stale deposit-only cache.
function walletStub() {
  const hooks = { saved: [], realtime: [] };
  const wallet = {
    hooks, mnemonic, offline: false, netName: 'mainnet', _savedAt: now,
    txs: [], _cacheExtensions: [],
    registerCacheSavedHook: fn => hooks.saved.push(fn),
    registerLoadHook: () => {}, registerRealtimeHook: h => hooks.realtime.push(h),
    saveCache: () => {}, emit: () => {}, _sortTxs: () => {},
    _mergeSnapshotExtensions(s) { if (s.arkState) this.arkState = mergeArkStates(this.arkState, s.arkState); },
    _applySnapshot: () => {},
  };
  return wallet;
}
const featureStorage = new Storage();
globalThis.localStorage = featureStorage;
const featureBox = new SyncOutbox({ ...deps, storage: featureStorage, send: async () => false });
const wallet = walletStub();
installSyncWallet(wallet, { outbox: featureBox });
wallet.nostr.load(mnemonic);
let liveArk = restored.arkState;
wallet._cacheExtensions = [{ domain: 'ark', save: () => ({ arkState: liveArk, arkServer: 'asp' }) }];
wallet.hooks.saved[0]({ ...restored, txs: [], utxos: [] });
for (const hook of wallet.hooks.realtime) hook.stop?.();
assert.equal(featureBox.events(a.pk).length, 2);
const captured = featureBox.events(a.pk).map(e => e.id);
wallet.hooks.saved[0]({ ...restored, savedAt: now + 50, txs: [], utxos: [] });
assert.deepEqual(featureBox.events(a.pk).map(e => e.id), captured, 'timestamp-only saves must not republish');
liveArk = { vtxos: [{ id: 'foreign-coin', amountSat: 99 }] };
wallet.nostr.load(mnemonic, 'different wallet');
const state = a.decodeStateEvents(featureBox.events(a.pk)).find(s => s.state.arkState).state.arkState;
assert.equal(balance(state), 4499, 'extension state is captured before account switch');
console.log('✓ realtime stop preserves snapshots; account switches cannot mix domains');

const fresh = walletStub();
installSyncWallet(fresh, { outbox: featureBox });
fresh.nostr.load(mnemonic);
fresh.arkState = { vtxos: [{ id: 'deposit', state: 'spendable', amountSat: 5000 }], movements: [], actions: [] };
fresh.nostr.fetchAllStates = async () => [];
assert.equal(await fresh.syncFromNostr(), true);
assert.equal(balance(fresh.arkState), 4499);
assert.equal(fresh.arkState.movements[0].amountSat, 501);
assert.equal(fresh.arkState.nextKeyIndex, 19);
console.log('✓ fresh session merges encrypted local recovery while relay is unavailable');

// Do not apply a fetched snapshot if the user switched accounts during I/O.
let finishFetch;
fresh.nostr.fetchAllStates = () => new Promise(resolve => { finishFetch = resolve; });
const fetching = fresh.syncFromNostr();
fresh.nostr.load(mnemonic, 'different wallet');
finishFetch([{ state: restored, dtag: 'bitcoin-wallet:x:test:ark', created_at: now / 1000 }]);
assert.equal(await fetching, false);
console.log('✓ account switch during fetch rejects the previous wallet result');

// Stored or relayed events must pass signatures as well as decryption.
const corrupt = { ...JSON.parse(JSON.stringify(received[0])), sig: '00'.repeat(64) };
assert.deepEqual(a.decodeStateEvents([corrupt]), []);
console.log('✓ invalid recovery signatures are rejected');

// The receiving manager is already connected when the relay supplies change.
// Update its live balance and history, keeping references held by an awaiting
// payment driver intact. A second identical snapshot must not trigger a loop.
const pendingCoin = { id: 'pending', amountSat: 100, state: 'pending' };
const pendingAction = { id: 'in-flight', type: 'ln-pay', step: 'initiated' };
const managerState = { vtxos: [pendingCoin, { id: 'deposit', state: 'spendable', amountSat: 5000 }], actions: [pendingAction], movements: [] };
let saves = 0;
const mgr = { state: managerState, _save: () => { saves++; } };
assert.equal(adoptArkSnapshot(mgr, restored.arkState), true);
assert.equal(balance(mgr.state), 4499);
assert.equal(mgr.state.movements[0].amountSat, 501);
assert.equal(mgr.state, managerState);
assert.equal(mgr.state.vtxos.find(v => v.id === 'pending'), pendingCoin);
assert.equal(mgr.state.actions[0], pendingAction);
pendingAction.step = 'done';
pendingCoin.state = 'spent';
assert.equal(mgr.state.actions[0].step, 'done');
assert.equal(mgr.state.vtxos.find(v => v.id === 'pending').state, 'spent');
assert.equal(adoptArkSnapshot(mgr, restored.arkState), false);
assert.equal(saves, 1);
console.log('✓ connected wallet adopts change without restarting or breaking active payments');

// Before the ASP handshake, every device snapshot is parked under its server
// key. Older snapshots arriving last must not overwrite newer change coins.
let extension;
const parkingWallet = { _cacheKey: () => 'parking-test', registerCacheExtension: e => { extension = e; } };
arkFeature({ wallet: parkingWallet, ui: {}, render: () => {} });
const serverPubkey = '02' + 'ab'.repeat(32);
const freshState = { ...restored.arkState, serverPubkey };
extension.load({ arkState: freshState });
extension.load({ arkState: { serverPubkey, vtxos: [], actions: [], movements: [] } });
const held = JSON.parse(localStorage.getItem(parkingWallet._arkHoldKey(serverPubkey)));
assert.equal(balance(held), 4499);
assert.equal(held.movements[0].amountSat, 501);
console.log('✓ older snapshots cannot overwrite recovery while the ASP is connecting');
featureBox.clear();
assert.equal(featureBox.records().length, 0);
console.log('✓ explicit forget-all removes encrypted recovery copies');
process.exit(0); // the feature schedules a lazy-connect callback; no live ASP in this test