From d59d667d3e4c6edb42b8a42fd120a1bdd96a58db Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 3 Aug 2026 20:15:16 -0700 Subject: Gift records belong to one wallet, not to the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit giftRecords() caches the parsed blob on the wallet object, but the app runs a single Wallet for its lifetime and load() never dropped that cache. Switching wallets carried the old wallet's gift links into the new one, where they all rendered as claimed — none of their outpoints are live in a wallet that never issued them — and the first save wrote them back under the new wallet's key. Clear the cache on load alongside the other two gift caches, and forget records whose funding tx isn't in this wallet's own history, to clean up what earlier builds already misfiled. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi --- src/features/gifts-wallet.js | 21 ++++++++++++ src/wallet.js | 1 + tools/gift-scope-test.js | 79 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 tools/gift-scope-test.js diff --git a/src/features/gifts-wallet.js b/src/features/gifts-wallet.js index 93ef268..bf8a82d 100644 --- a/src/features/gifts-wallet.js +++ b/src/features/gifts-wallet.js @@ -94,6 +94,26 @@ export function installGiftWallet(wallet) { _saveGiftRecords() { try { localStorage.setItem(this._giftRecordsKey(), JSON.stringify(this.giftRecords())); } catch {} }, + // We fund a gift from our own coins, so the funding tx is always in our own + // history. A record whose tx isn't means it was never issued here — an + // earlier build let a wallet keep the previous wallet's records in memory + // and then write them back under its own key. Forget those rather than + // show someone another wallet's gifts. + _pruneForeignGifts() { + const recs = this.giftRecords(); + const ids = Object.keys(recs); + if (!ids.length) return; + const ours = new Set(this.txs.map((t) => t.txid)); + let changed = false; + for (const id of ids) { + const outs = recs[id].outpoints || []; + if (outs.some((o) => ours.has(String(o).split(':')[0]))) continue; + delete recs[id]; + changed = true; + } + if (changed) this._saveGiftRecords(); + }, + // Remember which tx claimed a gift (resolved lazily via outspend) so the // history can merge the gift row with its claim transaction. setGiftClaimTx(id, txid) { @@ -125,6 +145,7 @@ export function installGiftWallet(wallet) { // once scanned, so an un-synced wallet doesn't mislabel everything as claimed.) claimedGifts() { if (!this.loaded) return []; + this._pruneForeignGifts(); const live = new Set(this.utxos.map((u) => utxoId(u))); return Object.values(this.giftRecords()) .filter((r) => !r.revoked && r.outpoints && !r.outpoints.some((o) => live.has(o))) diff --git a/src/wallet.js b/src/wallet.js index 9502700..d94b56f 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -192,6 +192,7 @@ export class Wallet { this._addrCache.clear(); this._reserved = null; // gift coins set aside from spending (lazy-loaded) this._reclaimed = null; // gift coins freed for spending but link still live + this._giftRecords = null; // gift links this wallet issued (lazy-loaded) this._savedAt = 0; try { for (const fn of this._loadHooks) { try { fn({ spFresh, netName, offline }); } catch {} } diff --git a/tools/gift-scope-test.js b/tools/gift-scope-test.js new file mode 100644 index 0000000..71f36af --- /dev/null +++ b/tools/gift-scope-test.js @@ -0,0 +1,79 @@ +// Gift records must not follow you from one wallet into the next. +// +// The records live under a seed-derived localStorage key, so on disk they were +// always separate. The leak was in memory: giftRecords() caches the parsed blob +// on the wallet object, the app runs a single Wallet for its whole lifetime, +// and load() never dropped that cache. Switch wallets and the old wallet's +// gifts were still sitting there — every one of them rendering as "claimed", +// since none of their outpoints are live in the new wallet. +import { Wallet } from '../src/wallet.js'; +import { installGiftWallet } from '../src/features/gifts-wallet.js'; + +const store = new Map(); +globalThis.localStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), +}; + +const A = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; +const B = 'legal winner thank year wave sausage worth useful legal winner thank yellow'; + +const wallet = new Wallet(); +installGiftWallet(wallet); + +let failures = 0; +const check = (name, ok, detail) => { + console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok || !detail ? '' : ` — ${detail}`}`); + if (!ok) failures++; +}; + +// Wallet A creates a gift and its record is cached in memory. +wallet.load({ mnemonic: A }); +wallet.giftRecords()['g1'] = { + id: 'g1', amount: 2100, created: Date.now() - 28 * 864e5, outpoints: ['deadbeef:0'], +}; +wallet._saveGiftRecords(); +check('wallet A sees its own gift', Object.keys(wallet.giftRecords()).length === 1); + +// Switch to a brand new wallet B. +wallet.load({ mnemonic: B }); +const leaked = Object.keys(wallet.giftRecords()); +check('wallet B starts with no gift records', leaked.length === 0, `saw ${JSON.stringify(leaked)}`); + +// And B must not write A's records under its own key. +wallet.giftRecords()['g2'] = { id: 'g2', amount: 500, created: Date.now(), outpoints: ['cafe:1'] }; +wallet._saveGiftRecords(); +wallet.load({ mnemonic: A }); +const backInA = Object.keys(wallet.giftRecords()); +check('wallet A is untouched by B', backInA.length === 1 && backInA[0] === 'g1', `saw ${JSON.stringify(backInA)}`); + +wallet.load({ mnemonic: B }); +const backInB = Object.keys(wallet.giftRecords()); +check('wallet B kept only its own', backInB.length === 1 && backInB[0] === 'g2', `saw ${JSON.stringify(backInB)}`); + +// Records an older build already wrote under the wrong key are forgotten on +// sight: their funding tx isn't in this wallet's history. +wallet.load({ mnemonic: B }); +store.set(wallet._giftRecordsKey(), JSON.stringify({ + 'aa:0': { id: 'aa:0', amount: 2100, created: Date.now() - 28 * 864e5, outpoints: ['aa:0'] }, + 'bb:0': { id: 'bb:0', amount: 2100, created: Date.now() - 28 * 864e5, outpoints: ['bb:0'] }, +})); +wallet.loaded = true; +wallet.txs = []; +check('foreign records are dropped from history', wallet.claimedGifts().length === 0); +check('and purged from storage', JSON.parse(store.get(wallet._giftRecordsKey())) && + !Object.keys(JSON.parse(store.get(wallet._giftRecordsKey()))).length); + +// But a gift this wallet really did issue still shows. +wallet.load({ mnemonic: B }); +store.set(wallet._giftRecordsKey(), JSON.stringify({ + 'cc:0': { id: 'cc:0', amount: 2100, created: Date.now() - 864e5, outpoints: ['cc:0'] }, +})); +wallet.loaded = true; +wallet.txs = [{ txid: 'cc' }]; +wallet.utxos = []; +check('our own claimed gift survives', wallet.claimedGifts().length === 1); + +console.log(failures ? `\n${failures} failing` : '\nall passing'); +process.exit(failures ? 1 : 0); -- cgit v1.2.3