aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
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
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')
-rw-r--r--src/features/gifts-wallet.js187
-rw-r--r--src/features/gifts.js34
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.