diff options
| author | Adam Soltys <[email protected]> | 2026-09-06 18:43:50 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-06 18:43:50 -0700 |
| commit | 2e492ec499264ba84ec8cad30f788eff28556269 (patch) | |
| tree | 53fb558113dea60a6902c5bccd082e07f0b8f220 /src/features/gifts.js | |
| parent | 95cf88f1246118eb6a672a5b4b6545331c9ddef4 (diff) | |
| download | coinosv3-2e492ec499264ba84ec8cad30f788eff28556269.tar.gz coinosv3-2e492ec499264ba84ec8cad30f788eff28556269.zip | |
Fix the on-chain gift front-running vulnerability architecturally: ephemeral-key (v2) gifts.
The old on-chain gift presigned a SIGHASH_NONE/SINGLE spend of the sender's own coin — a blank cheque whose claimer output isn't committed, so anyone (from the link, or a mempool watcher once a claim broadcast) could redirect the presigned input to their own address via a higher-fee tx. A sweeper bot had been front-running coinos gift links for ~a month; it drained a real 5000-sat gift ("claimed! → empty wallet"). Root-caused via chain + code forensics: theft tx input0 sighash = SINGLE.
New design (v2, lazy ephemeral key): the sender presigns a FULLY COMMITTED (SIGHASH_ALL) funding tx paying the gift to a throwaway key E, and puts E's private key in the link. The claimer broadcasts the funding tx, then a fresh SIGHASH_ALL sweep of E into their own wallet. Both txs commit to every output, so neither is redirectable in the mempool — redirecting needs E's key, which lives only in the link (the intended bearer property: holding the link is the only way to claim). Lazy: the funding tx isn't broadcast until claim, so an unclaimed gift moves no coins and is revoked for free by spending the still-reserved funding coin. Sender pays the funding fee (from change / the swept total); claimer pays the sweep fee (from the gift).
- gifts-wallet.js: _buildGiftPsbt now builds+signs the ephemeral funding tx (coin -> E + change, SIGHASH_ALL); new GIFT_MAGIC_V2 codec (parseLazyGift/_encodeLazyGift: [magic][E priv:32][le32 len][funding tx]); giftClaimTxs returns the {funding, sweep} pair; giftOutpoints/previewGift read E's output; isRetiredGift flags the old v1 (compact + legacy PSBT) links. Fee threaded through _selectGiftCoins/createGift/createGiftAll/createGiftFromSplit.
- gifts.js doClaim: broadcast funding (tolerating "already in mempool" from a co-holder/retry) then sweep; refuse retired v1 links with a clear message; verifyClaimWon now watches E's output.
- i18n: giftChainWarn reworded (safe bearer link funding a fresh on-chain wallet); giftRetired added.
Verified: focused crypto test confirms codec round-trip + both inputs signed SIGHASH_ALL (committed). Full regtest end-to-end: funding + sweep both relayed (child accepted spending an unconfirmed parent), mined, claimer address credited the gift. UI morph smoke passes.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
Diffstat (limited to 'src/features/gifts.js')
| -rw-r--r-- | src/features/gifts.js | 34 |
1 files changed, 24 insertions, 10 deletions
diff --git a/src/features/gifts.js b/src/features/gifts.js index 8aec560..4f5225c 100644 --- a/src/features/gifts.js +++ b/src/features/gifts.js @@ -5,7 +5,7 @@ import { newMnemonic } from '../wallet.js'; import { getNetwork } from '../api.js'; import { decryptWithCode, npubOf } from '../nostr.js'; -import { installGiftWallet, previewGift, giftOutpoints, buildClaimTx, giftMinimum, lockGift, previewLockedGift } from './gifts-wallet.js'; +import { installGiftWallet, previewGift, giftOutpoints, giftClaimTxs, isRetiredGift, giftMinimum, lockGift, previewLockedGift } from './gifts-wallet.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; import { fmtBtc, timeAgo, shortAddr } from '../format.js'; @@ -303,7 +303,8 @@ export function giftsFeature(ctx) { // amount — we surface the estimate on the Claim button so it isn't a // surprise. An ark gift sweeps off-chain: no fee at all. const rate = Math.max(1, Math.round((wallet.feeRates && wallet.feeRates.halfHourFee) || 5)); - const estFee = ag ? 0 : pv ? Math.ceil((11 + 68 * pv.inputs + 31 * 2) * rate) : 0; + // v2 sweep: pv.inputs (1) inputs, a single output to the claimer. + const estFee = ag ? 0 : pv ? Math.ceil((11 + 68 * pv.inputs + 31) * rate) : 0; const total = pv ? pv.room : 0; return h( 'div', @@ -446,8 +447,22 @@ export function giftsFeature(ctx) { return false; })(); if (!ownsTo) { ui.claimError = t('claimFailed'); ui.busy = false; render(); return; } - const claim = buildClaimTx(ui.claimCode, to, rate, wallet.netCfg.net); - await wallet.broadcast(claim.hex); + if (isRetiredGift(ui.claimCode)) { ui.claimError = t('giftRetired'); ui.busy = false; render(); return; } + // v2 ephemeral-key gift: broadcast the presigned funding tx (sender coin → + // ephemeral key E), then our SIGHASH_ALL sweep of E into this wallet. Both + // fully commit to their outputs, so neither is redirectable in the mempool + // — the front-running vector that drained the old blank-cheque gifts. + const claim = giftClaimTxs(ui.claimCode, to, rate, wallet.netCfg.net); + // The funding tx is deterministic (fully signed), so a retry — or a + // co-holder who already broadcast it — makes this a duplicate; that's + // success, not failure. Only a genuinely new broadcast needs to land + // before the sweep (which spends the funding output) can relay. + try { + await wallet.broadcast(claim.funding.hex); + } catch (e) { + if (!/already|mempool|txn-already|duplicate/i.test(String(e && e.message))) throw e; + } + await wallet.broadcast(claim.sweep.hex); afterClaim(claim.amount); wallet.scan().then(() => { // The claim credit advances the fresh receive index in the background; @@ -459,12 +474,11 @@ export function giftsFeature(ctx) { render(); } }).catch(() => {}); - // A broadcast that returns OK is not proof WE won: the on-chain gift's - // presigned input is redirectable, so a higher-fee sweep can evict our - // claim and the coin lands elsewhere — the "success then empty wallet" - // the sweeper bot produced. Watch the funding coin: if it gets spent by - // a txid that ISN'T ours, retract the celebration and say it was taken. - verifyClaimWon(giftOutpoints(ui.claimCode)[0], claim.txid).catch(() => {}); + // A claim that broadcasts OK is not absolute proof WE won: another holder + // of the same link could have swept E just before us (the bearer property, + // not the old theft — you can't win this without the link). Watch E's + // output; if a txid that ISN'T ours spent it, retract and say it was taken. + verifyClaimWon(claim.outpoint, claim.sweep.txid).catch(() => {}); } catch (e) { // Broadcast failed — most likely someone claimed it in the race window. // Re-check the funding coin and, if spent, show the "already claimed" screen. |
