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
245 lines
9.2 KiB
JavaScript
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,
|
|
};
|