aboutsummaryrefslogtreecommitdiffstats
path: root/lib/register.js
diff options
context:
space:
mode:
Diffstat (limited to 'lib/register.js')
0 files changed, 0 insertions, 0 deletions
/a> 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