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 | |
| 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')
| -rw-r--r-- | src/features/gifts-wallet.js | 187 | ||||
| -rw-r--r-- | src/features/gifts.js | 34 |
2 files changed, 168 insertions, 53 deletions
diff --git a/src/features/gifts-wallet.js b/src/features/gifts-wallet.js index c25e85a..920cddc 100644 --- a/src/features/gifts-wallet.js +++ b/src/features/gifts-wallet.js @@ -8,7 +8,8 @@ import { hex, base64urlnopad, base32nopad } from '@scure/base'; import { encryptGiftPayload } from '../nostr.js'; import { utxoId } from '../wallet.js'; import { p2wpkh } from '@scure/btc-signer/payment'; -import { concatBytes } from '@noble/hashes/utils'; +import { concatBytes, randomBytes } from '@noble/hashes/utils'; +import { secp256k1 } from '@noble/curves/secp256k1'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -158,9 +159,11 @@ export function installGiftWallet(wallet) { // change): the smallest single confirmed coin that covers it — one input and // the least value locked until claim — else the fewest large coins. Returns // the selected UTXOs, or null if confirmed funds are insufficient. - _selectGiftCoins(gift) { + _selectGiftCoins(gift, feeRate) { const DUST = 294n; - const need = gift + DUST; + const rate = Math.max(1, Math.round(feeRate || (this.feeRates && this.feeRates.halfHourFee) || 5)); + // need = gift + a funding fee (up to 2 inputs, E + change) + a dust change + const need = gift + BigInt(_giftFundFee(2, 2, rate)) + DUST; const avail = this.utxos.filter((u) => !this.isReserved(utxoId(u)) && (u.confirmed || u.chain === 1)); const single = avail.filter((u) => BigInt(u.value) >= need).sort((a, b) => a.value - b.value)[0]; if (single) return [single]; @@ -181,9 +184,9 @@ export function installGiftWallet(wallet) { const gift = BigInt(Math.round(amountSats)); const rate = Math.max(1, Math.round(feeRate)); if (gift < BigInt(giftMinimum(rate))) throw new Error('Gift amount is too small.'); - const sel = this._selectGiftCoins(gift); + const sel = this._selectGiftCoins(gift, rate); if (!sel) throw new Error('Not enough confirmed funds for that gift amount.'); - return this._buildGiftPsbt(sel, gift); + return this._buildGiftPsbt(sel, gift, rate); }, // Gift the entire spendable balance: a no-change sweep of all unreserved @@ -195,7 +198,7 @@ export function installGiftWallet(wallet) { if (!sel.length) throw new Error('No coins available to gift.'); const sum = sel.reduce((s, u) => s + BigInt(u.value), 0n); if (sum < BigInt(giftMinimum(rate))) throw new Error('Gift amount is too small.'); - return this._buildGiftPsbt(sel, sum); // change = 0 → no-change sweep + return this._buildGiftPsbt(sel, sum, rate); // gift >= sum → whole-balance, no change }, // Split + gift in one shot, with no confirmation wait: broadcast a self-send @@ -208,7 +211,9 @@ export function installGiftWallet(wallet) { const gift = Math.round(amountSats); const rate = Math.max(1, Math.round(feeRate)); if (BigInt(gift) < BigInt(giftMinimum(rate))) throw new Error('Gift amount is too small.'); - const DUST = 294; + // Carve out the gift PLUS the funding fee: the gift's presigned funding tx + // spends this one coin (gift → ephemeral key E) and pays its fee from it. + const DUST = _giftFundFee(1, 1, rate); const recvIndex = this.nextReceiveIndex; const carveAddr = this.derive(0, recvIndex).address; const changeIndex = this.nextChangeIndex; @@ -253,7 +258,7 @@ export function installGiftWallet(wallet) { } this._recomputeBalanceFromUtxos(); - return this._buildGiftPsbt([{ txid: splitTxid, vout: carveIdx, value: gift + DUST, chain: 0, index: recvIndex }], BigInt(gift)); + return this._buildGiftPsbt([{ txid: splitTxid, vout: carveIdx, value: gift + DUST, chain: 0, index: recvIndex }], BigInt(gift), rate); }, // The coins a gift of this amount would lock: total value + how many inputs. @@ -284,31 +289,58 @@ export function installGiftWallet(wallet) { return sel ? sel.reduce((s, u) => s + u.value, 0) : null; }, - // Build a signed gift from already-chosen coins. If the gift leaves a viable - // (>= dust) change, output0 is that change back to us, cryptographically fixed - // by signing input0 SIGHASH_SINGLE (the rest SIGHASH_NONE) — so our change is - // locked while the claimer is free to add their own output. If there's no - // viable change (a whole-balance gift), there's no output at all and every - // input is SIGHASH_NONE, so the claimer sweeps the entire amount. Reserves the - // coins; returns { code, amount, reserved } where amount is what the claimer - // receives before their fee (the gift, or the whole swept sum). - _buildGiftPsbt(sel, gift) { + // Build a presigned ephemeral-key gift from already-chosen coins, returning + // { code, amount, reserved } where amount is what the claimer receives + // before their own sweep fee. + // + // v2 (ephemeral-key) design: instead of presigning a SIGHASH_NONE/SINGLE + // spend of our own coin — a blank cheque a mempool watcher could redirect + // (the front-running theft vector that drained a real gift) — we presign a + // FULLY COMMITTED (SIGHASH_ALL) funding tx that pays the gift to a throwaway + // key E, and put E's private key in the link. The claimer broadcasts the + // funding tx and sweeps E (SIGHASH_ALL) into their own wallet. Neither tx is + // redirectable in the mempool (both commit to every output; redirecting + // needs E's key, which lives only in the link — so holding the link is the + // only way to claim, the intended bearer property). 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. + // + // The sender pays the funding fee (from change / the swept total); the + // claimer pays the sweep fee (from the gift). `gift >= sum` means "gift the + // whole balance": one output, no change. + _buildGiftPsbt(sel, gift, feeRate) { const DUST = 294n; + const rate = Math.max(1, Math.round(feeRate || (this.feeRates && this.feeRates.halfHourFee) || 5)); const sum = sel.reduce((s, u) => s + BigInt(u.value), 0n); - const change = sum - gift; - const keepChange = change >= DUST; - const sigOf = (i) => (keepChange && i === 0 ? btc.SigHash.SINGLE : btc.SigHash.NONE); + const whole = gift >= sum; // createGiftAll: gift the entire selection + let eAmount, change; + if (whole) { + eAmount = sum - _giftFundFee(sel.length, 1, rate); + change = 0n; + } else { + change = sum - gift - _giftFundFee(sel.length, 2, rate); + if (change >= DUST) { eAmount = gift; } + // change would be dust: drop it into the fee, single output, honour the + // requested amount (fee = sum - gift, which must clear a 1-output fee). + else { eAmount = gift; change = 0n; if (sum - gift < _giftFundFee(sel.length, 1, rate)) throw new Error('Not enough to cover the funding fee — try a smaller gift.'); } + } + if (eAmount < DUST) throw new Error('Gift amount is too small.'); + // the throwaway key that holds the gift until the claimer sweeps it + const epriv = randomBytes(32); + const epub = secp256k1.getPublicKey(epriv, true); + const eaddr = p2wpkh(epub, this.netCfg.net); const t = new btc.Transaction({ allowUnknownOutputs: true }); - sel.forEach((u, i) => { + sel.forEach((u) => { const pay = p2wpkh(this.derive(u.chain, u.index).pubkey, this.netCfg.net); - t.addInput({ ...pay, txid: u.txid, index: u.vout, sighashType: sigOf(i), witnessUtxo: { script: pay.script, amount: BigInt(u.value) } }); + t.addInput({ ...pay, txid: u.txid, index: u.vout, witnessUtxo: { script: pay.script, amount: BigInt(u.value) } }); }); - if (keepChange) t.addOutputAddress(this.freshChange().address, change, this.netCfg.net); - sel.forEach((u, i) => t.signIdx(this.node(u.chain, u.index).privateKey, i, [sigOf(i)])); - + t.addOutput({ script: eaddr.script, amount: eAmount }); // vout 0 = the gift, to E + if (change >= DUST) t.addOutputAddress(this.freshChange().address, change, this.netCfg.net); + sel.forEach((u, i) => t.signIdx(this.node(u.chain, u.index).privateKey, i)); // SIGHASH_ALL (default) + t.finalize(); for (const u of sel) this.reservedSet().add(utxoId(u)); this._saveReserved(); - return { code: base32nopad.encode(_encodeCompactGift(t)), amount: Number(keepChange ? gift : sum), reserved: sel.map(utxoId) }; + return { code: base32nopad.encode(_encodeLazyGift(epriv, t)), amount: Number(eAmount), reserved: sel.map(utxoId) }; }, }); // reserved gift coins are excluded from spending + the spendable balance @@ -344,11 +376,12 @@ export function installGiftWallet(wallet) { }); } -// Gift-link claiming (sender side is Wallet.createGift). A gift code is a -// base64url PSBT: one SIGHASH_SINGLE-signed input + the sender's change output. -// previewGift reports the room available to the claimer (before their fee); -// buildClaimTx adds the claimer's output and finalizes a broadcastable tx. -// Smallest sensible gift at a given fee rate: dust + one claim fee of headroom +// Gift-link claiming (sender side is Wallet.createGift). A v2 gift code carries +// a throwaway key E plus a fully-signed funding tx paying the gift to E (see +// parseLazyGift / giftClaimTxs). previewGift reports the value on E (before the +// claimer's sweep fee); giftClaimTxs returns the funding + sweep pair to +// broadcast. Retired v1 links (a redirectable presigned spend) are refused. +// Smallest sensible gift at a given fee rate: dust + one sweep fee of headroom // (floored at 546 sats), so the recipient clears dust even if fees climb. export function giftMinimum(feeRate) { const rate = Math.max(1, Math.round(feeRate)); @@ -356,13 +389,20 @@ export function giftMinimum(feeRate) { return Math.max(546, 294 + claimFee); } +// Fee (sats) for a p2wpkh tx of nIn inputs and nOut outputs at `rate` sat/vB: +// ~10.5 vB overhead + 68 vB per segwit input + 31 vB per output. +function _giftFundFee(nIn, nOut, rate) { + return Math.max(1, Math.ceil((11 + 68 * nIn + 31 * nOut) * Math.max(1, Math.round(rate)))); +} + // ---- compact gift codec ---------------------------------------------------- // A gift link used to carry a full PSBT (verbose). The claimer only needs the // presigned inputs (outpoint, amount, pubkey, signature) and the sender's // committed change output, so we serialize just those — ~25% smaller, and (via // base32 + an uppercase /g/ URL) it scans in the QR's denser alphanumeric mode. // Legacy base64url-PSBT links still claim; _giftTx auto-detects the format. -const GIFT_MAGIC = 0x01; +const GIFT_MAGIC = 0x01; // legacy blank-cheque on-chain gift — retired (front-runnable) +const GIFT_MAGIC_V2 = 0x02; // ephemeral-key gift: [magic][E privkey:32][le32 rawLen][funding tx] const _u8 = (n) => Uint8Array.of(n & 0xff); const _le32 = (n) => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n >>> 0, true); return b; }; const _le64 = (n) => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(n), true); return b; }; @@ -415,6 +455,39 @@ function _giftTx(code) { return btc.Transaction.fromPSBT(base64urlnopad.decode(code)); } +// ---- ephemeral-key (v2) gift codec ----------------------------------------- +// Serialize the throwaway key + the fully-signed funding tx. base32 keeps the +// /g/ URL in the QR's dense uppercase-alphanumeric mode. +function _encodeLazyGift(epriv, tx) { + const raw = hex.decode(tx.hex); // finalized, witness-carrying network tx + return concatBytes(_u8(GIFT_MAGIC_V2), epriv, _le32(raw.length), raw); +} +// Parse a v2 gift code → { epriv, tx } (the funding tx), or null if not v2. +export function parseLazyGift(code) { + try { + const b = base32nopad.decode(code.toUpperCase()); + if (b[0] !== GIFT_MAGIC_V2) return null; + let p = 1; + const epriv = b.slice(p, (p += 32)); + const len = new DataView(b.buffer, b.byteOffset + p, 4).getUint32(0, true); p += 4; + const raw = b.slice(p, (p += len)); + const tx = btc.Transaction.fromRaw(raw, { allowUnknownOutputs: true, allowUnknownInputs: true }); + return { epriv, tx }; + } catch { return null; } +} +// A gift link whose format can no longer be claimed safely (the retired v1 +// blank-cheque on-chain gift). Such links are only claimable by the sweeper +// bot that front-runs them, so we refuse rather than broadcast a doomed claim. +export function isRetiredGift(code) { + if (parseLazyGift(code)) return false; // v2 is fine + try { + const b = base32nopad.decode(code.toUpperCase()); + if (b[0] === GIFT_MAGIC) return true; // v1 compact + } catch {} + try { btc.Transaction.fromPSBT(base64urlnopad.decode(code)); return true; } catch {} + return false; // not an on-chain gift at all (e.g. an ark code) +} + function _sumInputs(tx) { let inAmt = 0n; for (let i = 0; i < tx.inputsLength; i++) inAmt += tx.getInput(i).witnessUtxo.amount; @@ -429,6 +502,12 @@ function _giftChange(tx) { // 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) { + // v2: the outpoint whose spend means "already claimed" is E's funding output + // (funding txid : 0). Its txid is deterministic (the funding tx is fully + // signed), so we can name it before the funding tx is ever broadcast — an + // outspend check on it gates the claim screen exactly like v1's input did. + const g = parseLazyGift(code); + if (g) return [{ txid: g.tx.id, vout: 0 }]; try { const tx = _giftTx(code); const out = []; @@ -442,26 +521,48 @@ export function giftOutpoints(code) { } } export function previewGift(code) { + const g = parseLazyGift(code); + if (g) { + // room = the value sitting on E; the claimer's sweep fee (one input, one + // output) comes out of it at claim time. + return { room: Number(g.tx.getOutput(0).amount), inputs: 1 }; + } try { const tx = _giftTx(code); - // room is the full amount the claimer receives (inputs minus our change); - // the claim fee is subtracted from it at claim time, so report inputs too - // so the caller can size that fee for this PSBT. return { room: Number(_sumInputs(tx) - _giftChange(tx)), inputs: tx.inputsLength }; } catch { return null; } } -export function buildClaimTx(code, toAddress, feeRate, net) { - const tx = _giftTx(code); - const room = _sumInputs(tx) - _giftChange(tx); - const fee = BigInt(Math.max(1, Math.ceil((11 + 68 * tx.inputsLength + 31 * 2) * Math.max(1, Math.round(feeRate))))); - const out = room - fee; +// Build the transactions that claim a v2 (ephemeral-key) gift into `toAddress`: +// the presigned funding tx (sender coin → E) then a fresh SIGHASH_ALL sweep +// (E → claimer). Broadcast funding first, then sweep. Both fully commit to +// their outputs, so nothing is redirectable in the mempool. Retired v1 links +// throw. Returns { funding, sweep, amount, fee, outpoint }. +export function giftClaimTxs(code, toAddress, feeRate, net) { + const g = parseLazyGift(code); + if (!g) throw new Error('This gift link uses a retired format and can no longer be claimed safely.'); + const { epriv, tx: funding } = g; + const fundingTxid = funding.id; + const eAmount = funding.getOutput(0).amount; // E's output, vout 0 + const epub = secp256k1.getPublicKey(epriv, true); + const eaddr = p2wpkh(epub, net); + const sweep = new btc.Transaction(); + sweep.addInput({ ...eaddr, txid: fundingTxid, index: 0, witnessUtxo: { script: eaddr.script, amount: eAmount } }); + const fee = BigInt(_giftFundFee(1, 1, feeRate)); + const out = eAmount - fee; if (out < 294n) throw new Error('Gift is too small to claim at this fee rate.'); - tx.addOutputAddress(toAddress, out, net); - tx.finalize(); - return { hex: tx.hex, txid: tx.id, amount: Number(out), fee: Number(fee) }; + sweep.addOutputAddress(toAddress, out, net); + sweep.signIdx(epriv, 0); // SIGHASH_ALL (default) + sweep.finalize(); + return { + funding: { hex: funding.hex, txid: fundingTxid }, + sweep: { hex: sweep.hex, txid: sweep.id }, + amount: Number(out), + fee: Number(fee), + outpoint: { txid: fundingTxid, vout: 0 }, + }; } // A gift locked to a recipient's nostr key. The amount + recipient pubkey are 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. |
