diff options
| -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); |
