aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-10 07:28:54 -0700
committerAdam Soltys <[email protected]>2026-09-10 07:28:54 -0700
commitf2de9b1330dfad5990f8e0ea6130b91096a60e54 (patch)
treeec87386a91fa126d9e53877da2e38d884c312cdf
parentaf4810e2ec8e454e4bb616c685e907eaee04c874 (diff)
downloadcoinosv3-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
-rw-r--r--src/features/sync.js14
-rw-r--r--tools/sync-zombie-tx-test.js37
2 files changed, 49 insertions, 2 deletions
diff --git a/src/features/sync.js b/src/features/sync.js
index a800ef8..415717f 100644
--- a/src/features/sync.js
+++ b/src/features/sync.js
@@ -223,9 +223,19 @@ export function installSyncWallet(wallet, { outbox = syncOutbox() } = {}) {
// 2026-09-04) while a fat core sat shadowed in an older device slot.
// Safe because history is append-mostly and every SUCCESSFUL local
// scan still prunes rows its addresses no longer report.
+ // …but a PENDING row from another device's snapshot is only adopted
+ // while it is fresh. A tx the wallet built that the network never took
+ // (or that was replaced) is pruned by the local scan, yet a stale device
+ // slot keeps publishing it — and this union used to resurrect it on
+ // every boot, then republish it: a phantom "withdrawal" that no explorer
+ // knows, immortal across devices. Confirmed rows are history; pending
+ // ones must prove themselves to the local scan within a day.
+ const FRESH_MS = 24 * 3600_000;
+ const adoptable = (t) => t.confirmed || Date.now() - (t.firstSeen || 0) < FRESH_MS;
const byId = new Map();
- for (const list of [this.txs || [], localTxs, ...fulls.map((s) => s.state.txs || [])]) {
- for (const t of list) if (t && t.txid && !byId.has(t.txid)) byId.set(t.txid, t);
+ for (const t of [...(this.txs || []), ...localTxs]) if (t && t.txid && !byId.has(t.txid)) byId.set(t.txid, t);
+ for (const s of fulls) {
+ for (const t of s.state.txs || []) if (t && t.txid && adoptable(t) && !byId.has(t.txid)) byId.set(t.txid, t);
}
if (byId.size > (this.txs || []).length) {
this.txs = [...byId.values()];
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');