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:
parent
3264c6d019
commit
e72c0ed96b
3 changed files with 71 additions and 18 deletions
|
|
@ -10,6 +10,7 @@
|
||||||
// supports the resulting scripts because bitcoinjs-lib does. An explicit
|
// supports the resulting scripts because bitcoinjs-lib does. An explicit
|
||||||
// address-family picker like DGB's is a follow-up.
|
// address-family picker like DGB's is a follow-up.
|
||||||
|
|
||||||
|
const { verifyFunding, assertFee } = require("./utxo-verify.js");
|
||||||
const NETWORKS = {
|
const NETWORKS = {
|
||||||
mainnet: {
|
mainnet: {
|
||||||
id: "mainnet", label: "Mainnet",
|
id: "mainnet", label: "Mainnet",
|
||||||
|
|
@ -453,17 +454,10 @@ module.exports = function makeBtcAdapter({
|
||||||
}
|
}
|
||||||
|
|
||||||
async signAndBroadcast(plan) {
|
async signAndBroadcast(plan) {
|
||||||
// BIP44 inputs need the whole previous transaction (nonWitnessUtxo)
|
// Every non-taproot input's previous transaction is fetched and
|
||||||
// so PSBT can compute a legacy sighash; fetch each one in parallel
|
// checked against the value the plan used (lib/utxo-verify.js); BIP44
|
||||||
// before assembling the PSBT.
|
// inputs also need it whole (nonWitnessUtxo) for the legacy sighash.
|
||||||
const needsPrev = plan._chosen.filter((u) => u.entry.family === "bip44");
|
const prevHex = await verifyFunding({ chosen: plan._chosen, client: this._client, Transaction: bitcoinjs.Transaction });
|
||||||
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])));
|
|
||||||
}
|
|
||||||
const psbt = new Psbt({ network: this._bjsNet });
|
const psbt = new Psbt({ network: this._bjsNet });
|
||||||
for (const u of plan._chosen) {
|
for (const u of plan._chosen) {
|
||||||
// Signal replace-by-fee, so a payment sent at too low a rate can be
|
// 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();
|
psbt.finalizeAllInputs();
|
||||||
|
assertFee(psbt, plan);
|
||||||
const tx = psbt.extractTransaction();
|
const tx = psbt.extractTransaction();
|
||||||
const hex = tx.toHex();
|
const hex = tx.toHex();
|
||||||
const txid = await this._client.call("blockchain.transaction.broadcast", [hex]);
|
const txid = await this._client.call("blockchain.transaction.broadcast", [hex]);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
// object, so a seed exported here can be restored on iancoleman.io/bip39
|
// object, so a seed exported here can be restored on iancoleman.io/bip39
|
||||||
// or the SilentCode Digibyte web-wallet with matching addresses.
|
// or the SilentCode Digibyte web-wallet with matching addresses.
|
||||||
|
|
||||||
|
const { verifyFunding, assertFee } = require("./utxo-verify.js");
|
||||||
const NETWORK = "mainnet";
|
const NETWORK = "mainnet";
|
||||||
const DEFAULT_PURPOSE = 84;
|
const DEFAULT_PURPOSE = 84;
|
||||||
const EXPLORER_TX = "https://digiexplorer.info/tx/";
|
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
|
// P2PKH (BIP44) inputs need the whole previous transaction for the
|
||||||
// legacy sighash; fetch each one before assembling the PSBT.
|
// legacy sighash; fetch each one before assembling the PSBT.
|
||||||
const prevHex = new Map();
|
// Every non-taproot input is checked against its previous transaction
|
||||||
const needsPrev = chosen.filter((u) => u.entry.family === "bip44");
|
// first, so a server cannot shrink an input and turn change into fee
|
||||||
if (needsPrev.length) {
|
// (lib/utxo-verify.js).
|
||||||
const results = await Promise.all(needsPrev.map((u) => this._client.call("blockchain.transaction.get", [u.txid, false])));
|
const prevHex = await verifyFunding({ chosen, client: this._client, Transaction: bitcoinjs.Transaction });
|
||||||
needsPrev.forEach((u, i) => prevHex.set(u.txid, String(results[i])));
|
|
||||||
}
|
|
||||||
const psbtInputs = chosen.map((u) => {
|
const psbtInputs = chosen.map((u) => {
|
||||||
const fam = u.entry.family;
|
const fam = u.entry.family;
|
||||||
const inp = { txid: u.txid, vout: u.vout };
|
const inp = { txid: u.txid, vout: u.vout };
|
||||||
|
|
@ -476,7 +475,11 @@ module.exports = function makeDgbAdapter({
|
||||||
psbt.signInput(i, this._keys.signerFor(entry));
|
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]);
|
const broadcast = await this._client.call("blockchain.transaction.broadcast", [hex]);
|
||||||
if (typeof broadcast !== "string" || broadcast.length !== 64) {
|
if (typeof broadcast !== "string" || broadcast.length !== 64) {
|
||||||
throw new Error("broadcast rejected: " + JSON.stringify(broadcast));
|
throw new Error("broadcast rejected: " + JSON.stringify(broadcast));
|
||||||
|
|
|
||||||
55
bundled-addons/aegis/lib/utxo-verify.js
Normal file
55
bundled-addons/aegis/lib/utxo-verify.js
Normal 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 };
|
||||||
Loading…
Add table
Reference in a new issue