aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/gifts.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-06 18:43:50 -0700
committerAdam Soltys <[email protected]>2026-09-06 18:43:50 -0700
commit2e492ec499264ba84ec8cad30f788eff28556269 (patch)
tree53fb558113dea60a6902c5bccd082e07f0b8f220 /src/features/gifts.js
parent95cf88f1246118eb6a672a5b4b6545331c9ddef4 (diff)
downloadcoinosv3-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.js34
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.