// 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, };