Files
AutistMask/src/shared/approvalTx.js
T
clawbot 2442441830
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: use the gas limit a site sends as gas (closes #207)
Sites send the gas limit under its JSON-RPC name, `gas`. ethers only
reads `gasLimit`, so the field was dropped and the node's estimate was
shown and signed instead. The page's request now takes `gas` as the gas
limit, and the existing ceiling, display and comparison with the signed
transaction cover it as they already did `gasLimit`. A request that
gives both with different values is refused.

Model: opus-5-5
2026-10-14 13:48:52 +02:00

245 lines
9.2 KiB
JavaScript

// Preparation of the transaction an approval screen displays.
//
// A dApp's eth_sendTransaction normally fixes `to`, `value` and `data`, and
// sometimes the gas limit. The nonce, the fees, and the gas limit when the
// site gives none have to be filled in from the network before anything can
// be signed, and whoever fills them in decides what the user is shown. That
// work used to happen in the popup, after the user had already approved: the
// numbers on the approval screen came from the popup and were compared against
// nothing, so a compromised popup could display one fee and sign another, and
// the ceilings in approvalVerify.js were all that stood between the user and a
// fee that hands the validator the balance.
//
// So it happens here instead, in the background, before the approval window is
// opened. The background populates the transaction, shows that object, and
// verifies the signed artifact against that same object — the popup is handed
// a finished transaction and signs it as given. Every field the user reads is
// then a field that is compared.
//
// The cost is an RPC round trip before the approval window exists. Nothing is
// displayed while it is in flight, and a failure — an unreachable node, a
// reverting gas estimate, a transaction type this wallet does not sign, a fee
// past the ceilings — means no approval and no window at all: the error goes
// back to the requesting page, which is where the user's click came from. That
// is deliberate. The alternative, opening the window first and populating
// behind a spinner, needs a pending approval that exists before it can be
// displayed or signed, and a half-initialised approval is exactly the state
// the settle interlock in the background exists to keep out of that record.
// The failure also lands earlier than it used to rather than later: the same
// estimate previously failed after the user had typed their password.
const {
VoidSigner,
accessListify,
getAddress,
getBigInt,
getBytes,
hexlify,
toQuantity,
} = require("ethers");
const {
ALLOWED_TX_TYPES,
SERIALIZED_FIELDS,
assertWithinCeilings,
} = require("./approvalVerify");
// How long the population may take before the request is failed back to the
// page. Without a bound a hung RPC endpoint leaves the dApp's promise pending
// forever with nothing on screen to explain it; ethers' own request timeout is
// minutes long, which is not a wait anyone will sit through.
const POPULATE_TIMEOUT_MS = 20000;
// The request fields taken from the page, plus `gas`, which requestFrom()
// takes as the gas limit. Anything else is dropped rather than passed to
// ethers: the object is page-controlled, and a future ethers that learns to
// carry a new transaction field must not start picking one up out of it
// without this module knowing.
//
// The nonce is not taken from the page; it is always the account's next nonce
// from the network. A page that chose it could replace one of the user's
// pending transactions (the same nonce at a higher fee) or leave this one stuck
// behind a gap (a nonce above the next one).
const REQUEST_FIELDS = [
"to",
"value",
"data",
"gasLimit",
"gasPrice",
"maxFeePerGas",
"maxPriorityFeePerGas",
"chainId",
"accessList",
"type",
];
class ApprovalPrepareError extends Error {
constructor(message) {
super(message);
this.name = "ApprovalPrepareError";
}
}
function fail(message) {
return new ApprovalPrepareError(message);
}
function present(v) {
return v !== null && v !== undefined && v !== "";
}
// Whether two quantities from the page are the same number. One that is not a
// number matches nothing.
function sameQuantity(a, b) {
try {
return getBigInt(a) === getBigInt(b);
} catch {
return false;
}
}
// These strings reach the user through the requesting page, so they are full
// sentences even when the tail of one came from ethers or from the node.
function sentence(text) {
return /[.!?]$/.test(text) ? text : text + ".";
}
// Reject a promise that has taken too long, and never leave the timer behind.
async function withTimeout(promise, ms, message) {
let timer = null;
try {
return await Promise.race([
promise,
new Promise((_resolve, reject) => {
timer = setTimeout(() => reject(fail(message)), ms);
}),
]);
} finally {
if (timer !== null) clearTimeout(timer);
}
}
// The page's request, reduced to the fields this wallet acts on.
function requestFrom(txParams, from) {
const request = { from: getAddress(from) };
for (const key of REQUEST_FIELDS) {
if (present(txParams[key])) request[key] = txParams[key];
}
// JSON-RPC calls the gas limit `gas`, and that is the name sites send.
// ethers only reads `gasLimit`: left as `gas` it would be dropped and an
// estimate signed in its place.
if (present(txParams.gas)) {
if (
present(request.gasLimit) &&
!sameQuantity(request.gasLimit, txParams.gas)
) {
throw fail(
"The site asked for two different gas limits for this transaction.",
);
}
request.gasLimit = txParams.gas;
}
if (
present(request.type) &&
!ALLOWED_TX_TYPES.includes(Number(request.type))
) {
throw fail(
"The site asked for a transaction of a type this wallet does not sign.",
);
}
return request;
}
// Turn a populated transaction into the object that crosses to the popup, is
// displayed, and is compared with the signed artifact. It carries exactly the
// fields its type serializes, plus the address it is to be signed by, and
// every quantity as a hex string: extension messaging is JSON, which has no
// bigint, and a field that did not survive the trip would be a field the user
// was shown and nothing compared.
function serializeApprovedTx(populated, from) {
const type = Number(populated.type);
if (!ALLOWED_TX_TYPES.includes(type)) {
throw fail(
"This transaction would have to be sent as a type this wallet does not sign.",
);
}
const approved = { type, from: getAddress(from) };
for (const key of SERIALIZED_FIELDS[type]) {
if (key === "to") {
approved.to = present(populated.to)
? getAddress(populated.to)
: null;
} else if (key === "data") {
approved.data = present(populated.data)
? hexlify(getBytes(populated.data))
: "0x";
} else if (key === "accessList") {
approved.accessList = accessListify(populated.accessList || []);
} else if (key === "value") {
approved.value = toQuantity(populated.value || 0);
} else if (!present(populated[key])) {
// Unreachable while populateTransaction() fills every quantity of
// the type it produced. If it ever does not, the approval must not
// be raised: an unfixed quantity is one the artifact cannot be
// checked against.
throw fail(
"The transaction could not be prepared: the network did not supply a " +
key +
".",
);
} else {
approved[key] = toQuantity(populated[key]);
}
}
return approved;
}
// Populate the transaction a site asked for, as the address it will be signed
// by, and return the object to display, sign and verify against. Throws with a
// full sentence when no approval can be raised.
async function prepareApprovalTx(provider, from, txParams) {
if (!present(from)) {
throw fail("There is no active address to send this transaction from.");
}
const request = requestFrom(txParams || {}, from);
let populated;
try {
// The sequence ethers' own sendTransaction() runs internally, so the
// nonce, gas, fee and chain id are populated exactly as they were when
// the popup did this. VoidSigner cannot sign, which is the point: the
// background prepares, the popup signs.
populated = await withTimeout(
new VoidSigner(getAddress(from), provider).populateTransaction(
request,
),
POPULATE_TIMEOUT_MS,
"The transaction could not be prepared: the network did not answer in time.",
);
} catch (e) {
if (e instanceof ApprovalPrepareError) throw e;
throw fail(
sentence(
"The transaction could not be prepared: " +
(e.shortMessage ||
e.message ||
"the network did not answer"),
),
);
}
const approved = serializeApprovedTx(populated, from);
// The backstop, applied before the user is shown anything rather than
// after they have approved it: what is displayed here is what gets signed,
// so an RPC node reporting an absurd fee has to be refused here.
assertWithinCeilings(approved);
return approved;
}
module.exports = {
prepareApprovalTx,
serializeApprovedTx,
ApprovalPrepareError,
POPULATE_TIMEOUT_MS,
REQUEST_FIELDS,
};