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.
55 lines
2.8 KiB
JavaScript
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 };
|