From 124fee3c15556d62d6efd8714fd4501e95273ebd Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 25 Jun 2026 16:01:39 -0700 Subject: Gift claim: detect already-claimed (even unconfirmed) and block the race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a gift link now checks whether its funding coin is already spent — via the Esplora outspend endpoint, which reports a spend even by an unconfirmed claim. The Claim button is gated until that check passes; if the coin is spent, show an 'Already claimed' screen with a link to the claim tx instead of a claimable amount. doClaim also re-checks on broadcast failure (the race window), and refuses to run while unchecked/taken — so a second opener can't attempt a doomed double-spend. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api.js | 6 ++++++ src/app.js | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--- src/i18n.js | 2 ++ src/wallet.js | 16 ++++++++++++++++ 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/api.js b/src/api.js index 20041e2..1f57353 100644 --- a/src/api.js +++ b/src/api.js @@ -209,6 +209,12 @@ export class Api { return this.#get(`/address/${address}/utxo`); } + // Spend status of one output: { spent, txid (spender), vin, status }. Reports + // a spend even by an unconfirmed tx — used to tell if a gift was already claimed. + outspend(txid, vout) { + return this.#get(`/tx/${txid}/outspend/${vout}`); + } + addressTxs(address) { return this.#get(`/address/${address}/txs`); } diff --git a/src/app.js b/src/app.js index 0730171..47f37fa 100644 --- a/src/app.js +++ b/src/app.js @@ -4,7 +4,7 @@ // which rebuilds the active screen. Text inputs write back into `ui` on `input` // (without re-rendering) so their values survive structural re-renders. -import { Wallet, newMnemonic, isValidMnemonic, utxoId, previewGift, buildClaimTx, giftMinimum, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js'; +import { Wallet, newMnemonic, isValidMnemonic, utxoId, previewGift, giftOutpoints, buildClaimTx, giftMinimum, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js'; import { qrSvg } from './qr.js'; import { scanQr } from './scan.js'; import { getSyncConfig, setSyncConfig } from './nostr.js'; @@ -25,6 +25,8 @@ const wallet = new Wallet(); const ui = { screen: 'unlock', // 'unlock' | 'wallet' | 'claim' | 'howItWorks' claimStep: null, // 'welcome' | 'backup' when opening a gift link + claimChecking: false, // verifying the gift's funding coin is still unspent + claimTaken: null, // { txid } if the gift was already claimed (coin spent) returnScreen: 'unlock', // where 'howItWorks' returns to (Back / logo) unlockTab: 'create', // 'create' | 'import' | 'watch' watchXpub: '', // watch-only xpub/zpub input @@ -601,6 +603,8 @@ async function activateAccount(acc, opts = {}) { ui.claimStep = 'welcome'; ui.claimCode = opts.gift || null; ui.claimError = ''; + ui.claimTaken = null; + ui.claimChecking = !!opts.gift; // gate the Claim button until we've checked ui.tab = 'receive'; // Not baselined yet — stays null until the scan + ack logic below sets it, // so the celebration never fires for payments that were already there at @@ -642,6 +646,10 @@ async function activateAccount(acc, opts = {}) { // discovery scan. The cache/Nostr state is already on screen. wallet.startRealtime(); + // Opened a gift link? Verify its funding coin is still unspent before letting + // the claim proceed — so a second opener can't try to race/double-spend. + if (opts.gift) checkGiftClaimed(opts.gift); + // Cross-device state in the background. State comes from the local cache or // Nostr — both restore the full balance, coins, and history — so a full API // scan runs ONLY when we have neither (e.g. a seed imported on a fresh device @@ -1398,9 +1406,27 @@ function readGiftHash() { } } +// Has this gift already been claimed? Its funding coin being spent — even by an +// unconfirmed claim — means the gift is gone; show the "already claimed" screen +// rather than let the user attempt a doomed double-spend. +async function checkGiftClaimed(code) { + const ops = giftOutpoints(code); + if (!ops.length) { ui.claimChecking = false; render(); return; } + try { + const res = await wallet.api.outspend(ops[0].txid, ops[0].vout); + if (res && res.spent) ui.claimTaken = { txid: res.txid || null }; + } catch { + // Couldn't check (offline/transient) — leave it claimable; the broadcast in + // doClaim is the final guard (a double-spend is rejected by the network). + } + ui.claimChecking = false; + render(); +} + // Broadcast the presigned gift to this fresh wallet's first receive address. async function doClaim() { if (wallet.offline) { ui.claimError = t('scanOffline'); render(); return; } + if (ui.claimChecking || ui.claimTaken) return; // not verified / already taken ui.busy = true; ui.claimError = ''; render(); @@ -1413,7 +1439,16 @@ async function doClaim() { ui.claimStep = 'backup'; wallet.scan().catch(() => {}); } catch (e) { - ui.claimError = t('claimFailed'); + // Broadcast failed — most likely someone claimed it in the race window. + // Re-check the funding coin and, if spent, show the "already claimed" screen. + const ops = giftOutpoints(ui.claimCode); + if (ops.length) { + try { + const res = await wallet.api.outspend(ops[0].txid, ops[0].vout); + if (res && res.spent) ui.claimTaken = { txid: res.txid || null }; + } catch {} + } + if (!ui.claimTaken) ui.claimError = t('claimFailed'); } ui.busy = false; render(); @@ -1483,6 +1518,23 @@ function claimScreen() { ) ); } + // Already claimed (funding coin spent, possibly only in the mempool) — show a + // dead end with a link to the claim, instead of a claimable amount. + if (ui.claimTaken) { + return h( + 'div', + { class: 'col', style: 'gap:16px' }, + brandHeader(false), + h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' }, + h('div', { style: 'font-size:56px;line-height:1' }, '🎁'), + h('h2', { style: 'margin:0' }, t('giftTakenTitle')), + h('p', { class: 'muted', style: 'margin:0' }, t('giftTakenBody')), + ui.claimTaken.txid + ? h('a', { class: 'btn btn-sm', href: wallet.api.explorerTx(ui.claimTaken.txid), target: '_blank', rel: 'noopener' }, t('viewOnMempool')) + : null + ) + ); + } const pv = previewGift(ui.claimCode); // The headline is the full amount received (inputs minus the sender's change). // The network fee is determined now, at claim time, and comes out of that @@ -1503,7 +1555,7 @@ function claimScreen() { h('p', { class: 'muted', style: 'margin:0' }, t('claimBody')) ), ui.claimError && h('div', { class: 'notice err' }, ui.claimError), - ui.busy + ui.busy || ui.claimChecking ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' })) : h('button', { class: 'btn-primary btn-block', onClick: doClaim }, t('claimBtn'), ' ', diff --git a/src/i18n.js b/src/i18n.js index 8efb930..65171f7 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -207,6 +207,8 @@ const STR = { giftSplitFirst: 'Split a coin first', giftLockWhole: 'Lock the whole coin ({n})', giftSplitFailed: 'Could not split the coin.', + giftTakenTitle: 'Already claimed', + giftTakenBody: 'Sorry, this gift has already been claimed.', giftHistoryTitle: 'Gift link', giftUnclaimedTag: 'unclaimed', giftCancel: 'Cancel', diff --git a/src/wallet.js b/src/wallet.js index ccd043a..e21710c 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -1748,6 +1748,22 @@ function _sumInputs(tx) { function _giftChange(tx) { return tx.outputsLength > 0 ? tx.getOutput(0).amount : 0n; } +// The gift's funding outpoints (the coins its presigned inputs spend). If any is +// already spent — even by an unconfirmed claim — the gift has been taken and +// re-claiming would be a doomed double-spend. Used to gate the claim screen. +export function giftOutpoints(code) { + try { + const tx = btc.Transaction.fromPSBT(base64urlnopad.decode(code)); + const out = []; + for (let i = 0; i < tx.inputsLength; i++) { + const inp = tx.getInput(i); + out.push({ txid: hex.encode(inp.txid), vout: inp.index }); + } + return out; + } catch { + return []; + } +} export function previewGift(code) { try { const tx = btc.Transaction.fromPSBT(base64urlnopad.decode(code)); -- cgit v1.2.3