summaryrefslogtreecommitdiffstats
path: root/src/wallet.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-24 14:01:39 -0700
committerAdam Soltys <[email protected]>2026-06-24 14:01:39 -0700
commit1a92d81b792f9e6d2ecb284aab979985a36d34c7 (patch)
treee5b358bcad0ba648363f373f9aa87e4c2f65045a /src/wallet.js
parentb78b9a1c7e4e42b7f673b0e3f92424a762bd0986 (diff)
downloadcoinosv3-1a92d81b792f9e6d2ecb284aab979985a36d34c7.tar.gz
coinosv3-1a92d81b792f9e6d2ecb284aab979985a36d34c7.zip
Keep reclaimed gifts revocable until the coin is spent
Passive reclaim previously dropped the coin from tracking, so there was no way to revoke afterward. Now reclaim moves the coin from "reserved" to a "reclaimed" set: it becomes spendable, but its still-live link stays listed (tagged "still claimable") with a Revoke option, until the coin is actually spent (by a payment or by revoking), at which point it drops off. revokeGift and outstandingGifts handle both sets; existing reserved gifts are unaffected. Strings updated across all 19 locales. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src/wallet.js')
-rw-r--r--src/wallet.js66
1 files changed, 46 insertions, 20 deletions
diff --git a/src/wallet.js b/src/wallet.js
index b5945ff..ad890d3 100644
--- a/src/wallet.js
+++ b/src/wallet.js
@@ -120,7 +120,8 @@ export class Wallet {
this.api.offline = offline;
this._account = null;
this._addrCache.clear();
- this._reserved = null; // gift-reserved outpoints, lazy-loaded per wallet
+ this._reserved = null; // gift coins set aside from spending (lazy-loaded)
+ this._reclaimed = null; // gift coins freed for spending but link still live
this._savedAt = 0;
try {
if (this.mnemonic) this.nostr.load(this.mnemonic, this.passphrase);
@@ -759,33 +760,47 @@ export class Wallet {
return { hex: t.hex, txid: t.id, fee: pl.fee, oldFee: prep.oldFee, feeRate: pl.rate, outputs, replaces: prep.txid };
}
- // --- gift links (coins set aside as claimable presigned transactions) ----
- // Outpoints reserved for unclaimed gifts are persisted per-wallet and skipped
- // by normal coin selection so a send can't spend a gift out from under it.
+ // --- gift links (coins backing claimable presigned transactions) ---------
+ // A gift coin is either RESERVED (set aside, skipped by coin selection) or
+ // RECLAIMED (freed for spending, but its link stays live until the coin is
+ // actually spent). Both are "outstanding"; both are persisted per-wallet.
_giftKey() {
return this._cacheKey() + ':gift';
}
- reservedSet() {
- if (!this._reserved) {
- try {
- this._reserved = new Set(JSON.parse(localStorage.getItem(this._giftKey()) || '[]'));
- } catch {
- this._reserved = new Set();
- }
+ _reclaimedKey() {
+ return this._cacheKey() + ':giftfree';
+ }
+ _loadSet(key) {
+ try {
+ return new Set(JSON.parse(localStorage.getItem(key) || '[]'));
+ } catch {
+ return new Set();
}
+ }
+ reservedSet() {
+ if (!this._reserved) this._reserved = this._loadSet(this._giftKey());
return this._reserved;
}
+ reclaimedSet() {
+ if (!this._reclaimed) this._reclaimed = this._loadSet(this._reclaimedKey());
+ return this._reclaimed;
+ }
isReserved(id) {
return this.reservedSet().has(id);
}
_saveReserved() {
- try {
- localStorage.setItem(this._giftKey(), JSON.stringify([...this.reservedSet()]));
- } catch {}
+ try { localStorage.setItem(this._giftKey(), JSON.stringify([...this.reservedSet()])); } catch {}
}
+ _saveReclaimed() {
+ try { localStorage.setItem(this._reclaimedKey(), JSON.stringify([...this.reclaimedSet()])); } catch {}
+ }
+ // Passive reclaim: free the coin for spending but keep tracking the live link
+ // so it can still be revoked later (until the coin is spent).
unreserve(id) {
this.reservedSet().delete(id);
+ this.reclaimedSet().add(id);
this._saveReserved();
+ this._saveReclaimed();
}
// Truly revoke an unclaimed gift: spend its coin back into our own wallet,
@@ -795,16 +810,27 @@ export class Wallet {
const draft = this.buildTx({ recipients: [{ address: this.freshChange().address }], feeRate, coinIds: [id], sendMax: true });
const hexTx = this.sign(draft.tx);
const txid = await this.broadcast(hexTx);
- this.unreserve(id);
+ this.reservedSet().delete(id);
+ this.reclaimedSet().delete(id);
+ this._saveReserved();
+ this._saveReclaimed();
return txid;
}
- // Active gifts whose coin is still unspent (drops claimed/spent ones).
- activeGifts() {
+ // Outstanding gifts (reserved + reclaimed) whose coin is still unspent; prunes
+ // any whose coin has since been claimed/spent. { id, reserved, value }.
+ outstandingGifts() {
const live = new Set(this.utxos.map((u) => utxoId(u)));
+ const res = this.reservedSet();
+ const rec = this.reclaimedSet();
let changed = false;
- for (const id of [...this.reservedSet()]) if (!live.has(id)) { this.reservedSet().delete(id); changed = true; }
- if (changed) this._saveReserved();
- return [...this.reservedSet()];
+ for (const id of [...res]) if (!live.has(id)) { res.delete(id); changed = true; }
+ for (const id of [...rec]) if (!live.has(id)) { rec.delete(id); changed = true; }
+ if (changed) { this._saveReserved(); this._saveReclaimed(); }
+ return [...new Set([...res, ...rec])].map((id) => ({
+ id,
+ reserved: res.has(id),
+ value: (this.utxos.find((u) => utxoId(u) === id) || {}).value,
+ }));
}
// Build a SIGHASH_SINGLE-signed gift: input = a single coin, output0 = change