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.
This commit is contained in:
Local Dev 2026-10-04 03:49:39 +02:00
parent 3264c6d019
commit e72c0ed96b
3 changed files with 71 additions and 18 deletions

View file

@ -10,6 +10,7 @@
// supports the resulting scripts because bitcoinjs-lib does. An explicit
// address-family picker like DGB's is a follow-up.
const { verifyFunding, assertFee } = require("./utxo-verify.js");
const NETWORKS = {
mainnet: {
id: "mainnet", label: "Mainnet",
@ -453,17 +454,10 @@ module.exports = function makeBtcAdapter({
}
async signAndBroadcast(plan) {
// BIP44 inputs need the whole previous transaction (nonWitnessUtxo)
// so PSBT can compute a legacy sighash; fetch each one in parallel
// before assembling the PSBT.
const needsPrev = plan._chosen.filter((u) => u.entry.family === "bip44");
const prevHex = new Map();
if (needsPrev.length) {
const results = await Promise.all(needsPrev.map((u) =>
this._client.call("blockchain.transaction.get", [u.txid, false])
));
needsPrev.forEach((u, i) => prevHex.set(u.txid, String(results[i])));
}
// Every non-taproot input's previous transaction is fetched and
// checked against the value the plan used (lib/utxo-verify.js); BIP44
// inputs also need it whole (nonWitnessUtxo) for the legacy sighash.
const prevHex = await verifyFunding({ chosen: plan._chosen, client: this._client, Transaction: bitcoinjs.Transaction });
const psbt = new Psbt({ network: this._bjsNet });
for (const u of plan._chosen) {
// Signal replace-by-fee, so a payment sent at too low a rate can be
@ -502,6 +496,7 @@ module.exports = function makeBtcAdapter({
}
}
psbt.finalizeAllInputs();
assertFee(psbt, plan);
const tx = psbt.extractTransaction();
const hex = tx.toHex();
const txid = await this._client.call("blockchain.transaction.broadcast", [hex]);

View file

@ -16,6 +16,7 @@
// object, so a seed exported here can be restored on iancoleman.io/bip39
// or the SilentCode Digibyte web-wallet with matching addresses.
const { verifyFunding, assertFee } = require("./utxo-verify.js");
const NETWORK = "mainnet";
const DEFAULT_PURPOSE = 84;
const EXPLORER_TX = "https://digiexplorer.info/tx/";
@ -442,12 +443,10 @@ module.exports = function makeDgbAdapter({
});
// P2PKH (BIP44) inputs need the whole previous transaction for the
// legacy sighash; fetch each one before assembling the PSBT.
const prevHex = new Map();
const needsPrev = chosen.filter((u) => u.entry.family === "bip44");
if (needsPrev.length) {
const results = await Promise.all(needsPrev.map((u) => this._client.call("blockchain.transaction.get", [u.txid, false])));
needsPrev.forEach((u, i) => prevHex.set(u.txid, String(results[i])));
}
// Every non-taproot input is checked against its previous transaction
// first, so a server cannot shrink an input and turn change into fee
// (lib/utxo-verify.js).
const prevHex = await verifyFunding({ chosen, client: this._client, Transaction: bitcoinjs.Transaction });
const psbtInputs = chosen.map((u) => {
const fam = u.entry.family;
const inp = { txid: u.txid, vout: u.vout };
@ -476,7 +475,11 @@ module.exports = function makeDgbAdapter({
psbt.signInput(i, this._keys.signerFor(entry));
}
}
const { hex, txid } = finalizeAndExtract(psbt);
psbt.finalizeAllInputs();
assertFee(psbt, plan);
const extracted = psbt.extractTransaction();
const hex = extracted.toHex();
const txid = extracted.getId();
const broadcast = await this._client.call("blockchain.transaction.broadcast", [hex]);
if (typeof broadcast !== "string" || broadcast.length !== 64) {
throw new Error("broadcast rejected: " + JSON.stringify(broadcast));

View file

@ -0,0 +1,55 @@
// 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 };