aboutsummaryrefslogtreecommitdiffstats
path: root/tools/spending-restore-test.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-18 23:12:18 -0700
committerAdam Soltys <[email protected]>2026-09-18 23:12:18 -0700
commit703ee6e1281e72ec00f65768d7de7a015c242abb (patch)
tree8a643d0f1ad6d6e49db210aec45341a7b119e5ab /tools/spending-restore-test.js
parent9e0f9528cd6726836386658c4f38ba8a42bea3ac (diff)
downloadcoinosv3-703ee6e1281e72ec00f65768d7de7a015c242abb.tar.gz
coinosv3-703ee6e1281e72ec00f65768d7de7a015c242abb.zip
Accounts from the header, snapshot-first Spending restore, name ownership grants.
Header buttons always leave the current feature screen; an account with no remembered nostr identity opens Accounts instead of nothing. Logout confirms in place. A scan finishing after logout or an account switch no longer drops the new session into offline mode (stateGeneration guards in sync + ark). Spending restores from the snapshot first. A migration grant names the identity that owns an address; its manager may register it without becoming that identity (names/ownership.js). Browser tests for account access, profile retry, spending restore, thread reply focus, custom-emoji reactions and registry ownership. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'tools/spending-restore-test.js')
-rw-r--r--tools/spending-restore-test.js119
1 files changed, 119 insertions, 0 deletions
diff --git a/tools/spending-restore-test.js b/tools/spending-restore-test.js
new file mode 100644
index 0000000..34cdd59
--- /dev/null
+++ b/tools/spending-restore-test.js
@@ -0,0 +1,119 @@
+// Snapshot-first Spending restore, without real relays, keys, or payments.
+// Run: bun tools/spending-restore-test.js
+import assert from 'node:assert/strict';
+import { slimArkForSync, mergeArkStates, arkFeature } from '../src/features/ark.js';
+import { installSyncWallet } from '../src/features/sync.js';
+import { ArkManager } from '../src/ark/manager.js';
+
+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); }
+}
+globalThis.localStorage = new Storage();
+globalThis.fetch = async () => { throw new Error('Network disabled in restore test'); };
+const serverPubkey = '02' + 'ab'.repeat(32);
+const empty = () => ({ v: 1, serverPubkey, mailboxCheckpoint: 0, vtxos: [], actions: [], movements: [], scheduled: [] });
+const full = {
+ ...empty(), mailboxCheckpoint: 123456, nextKeyIndex: 401,
+ vtxos: Array.from({ length: 400 }, (_, i) => ({ id: 'coin-' + i, state: 'spent', amountSat: 100, keyIndex: 0 })),
+ movements: Array.from({ length: 400 }, (_, i) => ({ id: 'move-' + i, type: 'receive', status: 'complete', vtxoId: 'coin-' + i, amountSat: 100, ts: 1700000000000 + i })),
+ actions: Array.from({ length: 120 }, (_, i) => ({ id: 'send-' + i, type: 'send', step: 'done', amountSat: 50, inputIds: ['coin-' + i] })),
+};
+const snapshot = slimArkForSync(full);
+assert.equal(snapshot.movements.length, 400);
+assert.equal(snapshot.vtxos.length, 400);
+assert.equal(snapshot.actions.length, 120);
+assert.deepEqual(slimArkForSync(snapshot).actions[0].inputIds, ['coin-0']);
+assert.equal(mergeArkStates(empty(), snapshot).mailboxCheckpoint, 123456);
+const local = { ...empty(), mailboxCheckpoint: 10, movements: [{ id: 'local' }] };
+assert.equal(mergeArkStates(local, snapshot).mailboxCheckpoint, 10);
+assert.equal(mergeArkStates({ ...empty(), actions: [{ id: 'pending', step: 'signing' }] }, snapshot).mailboxCheckpoint, 0);
+const older = { ...empty(), vtxos: [{ id: 'coin-0', amountSat: 100, state: 'spendable' }] };
+assert.equal(mergeArkStates(snapshot, older).vtxos.find(v => v.id === 'coin-0').state, 'spent');
+console.log('✓ complete history, spent stubs and spend provenance survive repeated snapshotting');
+console.log('✓ fresh restores inherit the cursor; existing or unfinished local work does not skip messages');
+
+const hooks = { realtime: [], load: [] };
+const wallet = {
+ offline: false, netName: 'mainnet', txs: [], _savedAt: 0,
+ registerCacheSavedHook() {}, registerLoadHook: f => hooks.load.push(f),
+ registerRealtimeHook: h => hooks.realtime.push(h),
+ _mergeSnapshotExtensions(s) { if (s.arkState) this.arkState = mergeArkStates(this.arkState, s.arkState); },
+ _applySnapshot() {}, emit() {}, saveCache() {},
+};
+installSyncWallet(wallet, { outbox: null });
+wallet.nostr.load('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');
+let fetched = 0, subscribed = 0, finish;
+wallet.nostr.fetchAllStates = () => { fetched++; return new Promise(r => { finish = r; }); };
+wallet.nostr.subscribeStates = () => { subscribed++; return () => {}; };
+hooks.realtime[0].start();
+const restore = wallet.syncFromNostr();
+assert.equal(fetched, 1);
+assert.equal(subscribed, 0);
+finish([
+ { created_at: 2, dtag: 'bitcoin-wallet:x:one:ark', state: { netName: 'mainnet', arkState: snapshot } },
+ { created_at: 1, dtag: 'bitcoin-wallet:x:two:ark', state: { netName: 'mainnet', arkState: older } },
+]);
+assert.equal(await restore, true);
+await Promise.resolve();
+assert.equal(subscribed, 1);
+assert.equal(wallet.arkState.movements.length, 400);
+assert.equal(wallet.arkState.vtxos[0].state, 'spent');
+console.log('✓ initial fetch is shared and sorted before live snapshot delivery begins');
+hooks.realtime[0].stop();
+wallet.nostrRestoreReady = null;
+hooks.realtime[0].start();
+hooks.realtime[0].stop();
+const stopped = wallet.nostrRestoreReady;
+finish([]); await stopped; await Promise.resolve();
+assert.equal(subscribed, 1);
+console.log('✓ logout during restore does not restart the old identity subscription');
+
+// Real Ark feature lifecycle, with only the manager's network operations
+// replaced. Exercise the first-login case where state is held until the ASP
+// handshake establishes which server owns it.
+const original = {};
+for (const name of ['init', 'sync', 'startMailboxStream', 'stopMailboxStream']) original[name] = ArkManager.prototype[name];
+let syncs = 0, streams = 0, releaseRestore, releaseCatchup, manager;
+const restored = new Promise(r => { releaseRestore = r; });
+const catchup = new Promise(r => { releaseCatchup = r; });
+ArkManager.prototype.init = async function () {
+ manager = this;
+ this.info = { serverPubkey };
+ this.state = this.storage.adopt(serverPubkey) || this.storage.load() || empty();
+ return this;
+};
+ArkManager.prototype.sync = async function () { syncs++; assert.equal(this.state.mailboxCheckpoint, 123456); await catchup; };
+ArkManager.prototype.startMailboxStream = function () { streams++; };
+ArkManager.prototype.stopMailboxStream = function () {};
+let extension;
+const arkWallet = {
+ _cacheKey: () => 'fresh-spending-test', account: () => ({}),
+ registerCacheExtension: e => { extension = e; },
+ nostrRestoreReady: restored, saveCache() {}, loadFeatureState: () => ({}),
+};
+const feature = arkFeature({ wallet: arkWallet, ui: {}, render() {}, hook() {}, getAccount: () => 'spending' });
+const pause = () => new Promise(r => setTimeout(r, 20));
+try {
+ feature.init(); // no local Spending state yet
+ extension.load({ arkState: snapshot });
+ await pause();
+ assert.ok(manager, 'held snapshot starts a connection immediately, without the old 20-second throttle');
+ assert.equal(syncs, 0);
+ assert.equal(streams, 0);
+ assert.equal(manager.state.movements.length, 400);
+ releaseRestore(true); await pause();
+ assert.equal(syncs, 1);
+ assert.equal(streams, 0);
+ releaseCatchup(); await pause();
+ assert.equal(streams, 1);
+ console.log('✓ fresh Spending adopts the whole snapshot before catch-up, then starts its live stream');
+} finally {
+ feature.stop();
+ for (const [name, fn] of Object.entries(original)) ArkManager.prototype[name] = fn;
+}
+process.exit(0);