diff options
| author | Adam Soltys <[email protected]> | 2026-06-25 16:01:39 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-25 16:01:39 -0700 |
| commit | 124fee3c15556d62d6efd8714fd4501e95273ebd (patch) | |
| tree | e8302cf0a2e913f11fc8a9bb883d3b99dbc427d2 | |
| parent | 5ed8b673bb0d6981a834d3b4d677c6678f0bd1dd (diff) | |
| download | coinosv3-124fee3c15556d62d6efd8714fd4501e95273ebd.tar.gz coinosv3-124fee3c15556d62d6efd8714fd4501e95273ebd.zip | |
Gift claim: detect already-claimed (even unconfirmed) and block the race
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) <[email protected]>
| -rw-r--r-- | src/api.js | 6 | ||||
| -rw-r--r-- | src/app.js | 58 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | src/wallet.js | 16 |
4 files changed, 79 insertions, 3 deletions
@@ -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`); } @@ -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)); |
