diff options
| author | Adam Soltys <[email protected]> | 2026-08-03 20:15:16 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-03 20:15:16 -0700 |
| commit | d59d667d3e4c6edb42b8a42fd120a1bdd96a58db (patch) | |
| tree | 0df9efb0d83b805bf284eef3868719426074f214 | |
| parent | a7c45156fb49e26f06a359d79240d328d88a00d5 (diff) | |
| download | coinosv3-d59d667d3e4c6edb42b8a42fd120a1bdd96a58db.tar.gz coinosv3-d59d667d3e4c6edb42b8a42fd120a1bdd96a58db.zip | |
Gift records belong to one wallet, not to the browser
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
| -rw-r--r-- | src/features/gifts-wallet.js | 21 | ||||
| -rw-r--r-- | src/wallet.js | 1 | ||||
| -rw-r--r-- | tools/gift-scope-test.js | 79 |
3 files changed, 101 insertions, 0 deletions
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); |
