aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/gifts-wallet.js21
-rw-r--r--src/wallet.js1
-rw-r--r--tools/gift-scope-test.js79
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);