Some checks failed
check / check (push) Has been cancelled
Verification compared the signed artifact against the dApp's request object. For every field the dApp omitted -- normally nonce, gas limit and all the fee fields, since the popup filled them in -- the number the user actually read on screen was verified by nothing, and only absolute ceilings stood behind it. The transaction is now populated in the background before the approval window opens, and that populated object is both what the popup displays and what the signed artifact is verified against. Every consequential field becomes an equality comparison; the ceilings remain as a backstop. Population failing means no approval and no window, and the error goes to the requesting page -- earlier than before, where the same estimate failed after the password had been typed. The account is pinned too: `from` is compared against the address named at approval time rather than whichever address is active at signing, so switching accounts mid-flow refuses instead of signing from an account the approval did not name. The message-signing path had the same defect and gets the same fix. Nonce selection moves earlier as a consequence; the concurrent-approval case that follows from it is tracked at #271.
214 lines
8.0 KiB
JavaScript
214 lines
8.0 KiB
JavaScript
// Preparation of the transaction an approval screen displays.
|
|
//
|
|
// A dApp's eth_sendTransaction normally fixes only `to`, `value` and `data`.
|
|
// The nonce, the gas limit and the fees 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,
|
|
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. 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.
|
|
const REQUEST_FIELDS = [
|
|
"to",
|
|
"value",
|
|
"data",
|
|
"nonce",
|
|
"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 !== "";
|
|
}
|
|
|
|
// 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];
|
|
}
|
|
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,
|
|
};
|