diff options
| author | Adam Soltys <[email protected]> | 2026-09-18 23:12:18 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-18 23:12:18 -0700 |
| commit | 703ee6e1281e72ec00f65768d7de7a015c242abb (patch) | |
| tree | 8a643d0f1ad6d6e49db210aec45341a7b119e5ab /tools/spending-restore-test.js | |
| parent | 9e0f9528cd6726836386658c4f38ba8a42bea3ac (diff) | |
| download | coinosv3-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.js | 119 |
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); |
