diff options
| author | Adam Soltys <[email protected]> | 2026-09-10 07:28:54 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-10 07:28:54 -0700 |
| commit | f2de9b1330dfad5990f8e0ea6130b91096a60e54 (patch) | |
| tree | ec87386a91fa126d9e53877da2e38d884c312cdf /tools | |
| parent | af4810e2ec8e454e4bb616c685e907eaee04c874 (diff) | |
| download | coinosv3-f2de9b1330dfad5990f8e0ea6130b91096a60e54.tar.gz coinosv3-f2de9b1330dfad5990f8e0ea6130b91096a60e54.zip | |
Sync: stop resurrecting stale pending txs from other devices' snapshots.
A 5,000-sat send built on 2026-09-06 that the network never took sat in the
history as a pending withdrawal for four days on every device. The local
scan prunes such rows (explorer 404 / absent from address history), but
the boot-time cross-device history union re-adopted it from a stale device
slot's core snapshot, and the device then republished it — a phantom that
no explorer knows, immortal across devices.
The union now adopts a remote PENDING row only while it is under a day old
(it has to prove itself to the local scan); confirmed rows still union as
before, and the device's own rows are untouched.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/sync-zombie-tx-test.js | 37 |
1 files changed, 37 insertions, 0 deletions
diff --git a/tools/sync-zombie-tx-test.js b/tools/sync-zombie-tx-test.js new file mode 100644 index 0000000..914bb04 --- /dev/null +++ b/tools/sync-zombie-tx-test.js @@ -0,0 +1,37 @@ +// The cross-device history union must not resurrect a stale pending tx from +// another device's snapshot (a never-broadcast/replaced tx that the local +// scan already pruned). Run: bun tools/sync-zombie-tx-test.js +import assert from 'node:assert/strict'; +import { SyncOutbox } from '../src/sync-outbox.js'; +import { installSyncWallet } from '../src/features/sync.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(); +const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; +const now = Date.now(); +const wallet = { + mnemonic, offline: false, netName: 'mainnet', _savedAt: 0, txs: [{ txid: 'mine-confirmed', confirmed: true, blockHeight: 10 }], _cacheExtensions: [], + registerCacheSavedHook: () => {}, registerLoadHook: () => {}, registerRealtimeHook: () => {}, + saveCache: () => {}, emit: () => {}, _sortTxs: () => {}, _mergeSnapshotExtensions: () => {}, _applySnapshot() {}, +}; +installSyncWallet(wallet, { outbox: new SyncOutbox({ storage: new Storage(), send: async () => true, schedule: () => 0, cancel: () => {} }) }); +wallet.nostr.load(mnemonic); +wallet.nostr.fetchAllStates = async () => [{ + dtag: 'bitcoin-wallet:x:dead-device:core', created_at: 1, + state: { netName: 'mainnet', savedAt: 1, txs: [ + { txid: 'zombie', confirmed: false, firstSeen: now - 4 * 86400_000, net: -5000 }, // 4 days pending, unknown to the network + { txid: 'fresh-pending', confirmed: false, firstSeen: now - 60_000, net: -1 }, // a minute old: give it a chance + { txid: 'old-confirmed', confirmed: true, blockHeight: 5 }, + ] }, +}]; +assert.equal(await wallet.syncFromNostr(), true); +const ids = wallet.txs.map((t) => t.txid).sort(); +assert.deepEqual(ids, ['fresh-pending', 'mine-confirmed', 'old-confirmed']); +console.log('✓ stale pending txs from other devices are not resurrected; confirmed and fresh ones still union'); |
