theseus/bundled-addons/aegis/lib/utxo-verify.js
Local Dev e72c0ed96b Aegis: check every BTC/DGB input against its previous transaction
Coin selection, change and the fee on the overlay came from the
Electrum server's listunspent values, and a legacy signature does not
commit to the value it spends. A server that under-reported a P2PKH
coin made Aegis sign away the difference as fee: a 1,000,000 sat coin
reported as 100,000 produced a transaction paying 901,180 sat while
the overlay said 1,180. Segwit v0 commits only to its own input, which
leaves the two-request variant open.

Every non-taproot input's previous transaction is now fetched, its txid
recomputed, and its output's value and script compared with the plan;
the fee of the finalized PSBT must equal the approved one.
2026-10-04 03:49:39 +02:00

55 lines
2.8 KiB
JavaScript

// Check the Electrum server's word on what each input is worth before
// signing a BTC/DGB transaction.
//
// Coin selection, change and the fee on the approval overlay are computed
// from listunspent's `value`. A legacy (P2PKH) signature does not commit to
// the value of the coin it spends, so a server that under-reported a UTXO
// made Aegis sign a transaction whose real fee was the difference — up to
// bitcoinjs's 5000 sat/vB ceiling — while the overlay showed the small,
// planned fee. A segwit v0 signature commits to its own input's value only,
// which still leaves the two-request variant (lie about a different input
// each time, combine the signatures). Taproot commits to every input's
// amount and script, so a lie there just makes the signature invalid.
//
// For every non-taproot input the previous transaction is fetched, its txid
// recomputed (so the server cannot hand over a different one), and the
// output's value and script compared with what was planned. Any mismatch
// refuses to sign.
"use strict";
async function verifyFunding({ chosen, client, Transaction }) {
const need = chosen.filter((u) => u.entry.family !== "bip86");
const uniq = [...new Set(need.map((u) => u.txid))];
const got = await Promise.all(uniq.map((txid) => client.call("blockchain.transaction.get", [txid, false])));
const prevHex = new Map();
uniq.forEach((txid, i) => prevHex.set(txid, String(got[i] || "")));
for (const u of need) {
const hex = prevHex.get(u.txid);
let tx;
try { tx = Transaction.fromHex(hex); }
catch { throw new Error(`the server sent an unreadable transaction for input ${u.txid}:${u.vout}; not signing`); }
if (tx.getId() !== u.txid) throw new Error(`the server sent a different transaction for input ${u.txid}:${u.vout}; not signing`);
const out = tx.outs[u.vout];
if (!out) throw new Error(`input ${u.txid}:${u.vout} does not exist; not signing`);
if (BigInt(out.value) !== BigInt(u.value)) {
throw new Error(`the server misreported input ${u.txid}:${u.vout} (said ${u.value}, the transaction says ${out.value}); not signing`);
}
const script = u.entry.script ? Buffer.from(u.entry.script) : (u.entry.scriptHex ? Buffer.from(u.entry.scriptHex, "hex") : null);
if (script && !Buffer.from(out.script).equals(script)) {
throw new Error(`input ${u.txid}:${u.vout} is not paid to this wallet's address; not signing`);
}
}
return prevHex;
}
// The fee the signed transaction actually pays must be the one the user
// approved.
function assertFee(psbt, plan) {
let actual;
try { actual = psbt.getFee(); } catch { return; } // a taproot-only PSBT without full prevouts
if (BigInt(actual) !== BigInt(plan.fee)) {
throw new Error(`the transaction would pay a fee of ${actual}, not the ${plan.fee} you approved; not signing`);
}
}
module.exports = { verifyFunding, assertFee };