// Verification of the signed artifacts produced by the approval popup. // // Signing happens in the popup, where the password is entered; the background // only broadcasts the raw transaction and resolves the pending approval back // to the requesting page. So that moving the signing out of the background // does not turn the background into a blind relay, the background re-derives // the signer from the artifact and checks it against the approval it is // holding before acting on it. All recovery is delegated to ethers. // // What the artifact is checked against is the transaction the background // populated and the popup displayed (see approvalTx.js), not the request the // dApp made. The two differ in every field a dApp normally leaves out — nonce, // gas limit, fees — and those are the fields the user reads off the approval // screen, so comparing against the request would leave the numbers on screen // vouched for by nothing. // // The check is an allowlist, in both directions, because a denylist cannot be // correct against a transaction format that keeps gaining fields: // // - only transaction types 0, 1 and 2 are accepted. Every later EIP-2718 type // adds a field with consequences of its own — EIP-7702's authorizationList // rewrites the code at the signer's own account, EIP-4844's blob // commitments carry a separate fee — and a check that enumerates the fields // it refuses admits every one of them by default. // - after the per-field comparisons, the artifact is rebuilt from those // checked fields and nothing else, and the two are compared byte for byte. // Anything the artifact carries that this module does not name is absent // from the rebuild and changes the bytes, so the final assertion is that // the artifact *is* the approved transaction, not merely that it is not one // of the tampered shapes that were thought of. // - every comparison runs against the decode, but the string handed to // broadcastTransaction() is the artifact. So the artifact is also required // to be the canonical re-encoding of its own decode, which is what makes // the checked transaction and the broadcast bytes the same object rather // than two things that merely decode alike. // // Every consequential field is compared, and a mismatch is a refusal to act, // never a warning: what the user approved is what gets broadcast, or nothing // does. // // The approved transaction is required to fix every field its type serializes, // so there is no "the approval did not say" branch to fall through: a quantity // the approval does not carry is a refusal, because an artifact that cannot be // compared with what was displayed has not been checked. The chain id is // checked against the selected network as well as against the approval, which // is what makes a cross-chain replay impossible. // // Every failure message is a full sentence, because these strings are shown to // the user and returned to the dApp. const { Transaction, accessListify, getAddress, getBytes, verifyMessage, verifyTypedData, } = require("ethers"); // The only transaction types this wallet signs: legacy, EIP-2930 and // EIP-1559. populateTransaction() produces nothing else, so nothing else can // be an artifact of an approval this wallet raised. const ALLOWED_TX_TYPES = [0, 1, 2]; // The serialized fields of each allowed type, which is also the complete set // of fields the checks below compare or bound. The artifact is rebuilt from // exactly these at the end of verification and compared byte for byte, so a // field outside this table cannot ride along unexamined. const SERIALIZED_FIELDS = { 0: ["chainId", "nonce", "gasPrice", "gasLimit", "to", "value", "data"], 1: [ "chainId", "nonce", "gasPrice", "gasLimit", "to", "value", "data", "accessList", ], 2: [ "chainId", "nonce", "maxPriorityFeePerGas", "maxFeePerGas", "gasLimit", "to", "value", "data", "accessList", ], }; // Fields no allowed type may carry. The type allowlist already excludes every // type that defines them, and the structural check at the end of verification // would catch them anyway; they are named here so that an artifact carrying // one is refused with a message that says what it was. const FORBIDDEN_FIELDS = [ { key: "authorizationList", message: "The signed transaction would hand the signing account over to another contract, which was not approved.", }, { key: "blobVersionedHashes", message: "The signed transaction carries blob commitments, which were not approved.", }, { key: "blobs", message: "The signed transaction carries blobs, which were not approved.", }, { key: "maxFeePerBlobGas", message: "The signed transaction carries a blob gas fee, which was not approved.", }, ]; // Absolute ceilings — a BACKSTOP, not the primary control. // // The primary control is equality: every field of the artifact is compared // with the populated transaction the user was shown, so nothing the popup // signs can differ from the screen. What equality cannot bound is the // populated transaction itself, which is built from what the configured RPC // node answered — a node that reports an absurd fee gets that fee displayed, // and a user who does not read the fee line would approve it. These ceilings // bound that, and they are therefore applied where the transaction is // populated (approvalTx.js) as well as here. // // Above the block gas limit of every supported network (see networks.js), so // no transaction that could ever be included is refused by it. const MAX_GAS_LIMIT = 100000000n; // 100,000 gwei per gas: orders of magnitude above the highest fee either // supported network has produced, and low enough to catch a fee that would // hand the validator the balance. const MAX_FEE_PER_GAS = 100000000000000n; // A refusal to act on an artifact: it is not the thing that was approved, so // the approval it was offered against is spent and must not be retried. Every // throw in this module is one of these; the background distinguishes them from // transient failures (a busy node, a failed broadcast), which leave the // approval standing so the user can try again. class ApprovalMismatchError extends Error { constructor(message) { super(message); this.name = "ApprovalMismatchError"; this.approvalMismatch = true; } } function refuse(message) { return new ApprovalMismatchError(message); } // Whether a signing failure leaves the approval usable. Anything that is not a // mismatch is the user's to correct and retry. function failureIsRetryable(err) { return !(err && err.approvalMismatch === true); } // Case-insensitive address comparison that tolerates absent values on either // side. Two absent addresses compare equal (contract creation has no `to`). function sameAddress(a, b) { const aMissing = a === null || a === undefined || a === ""; const bMissing = b === null || b === undefined || b === ""; if (aMissing || bMissing) return aMissing && bMissing; try { return getAddress(a) === getAddress(b); } catch { return String(a).toLowerCase() === String(b).toLowerCase(); } } // Whether the approval fixed a value for a field at all. function present(v) { return v !== null && v !== undefined && v !== ""; } // Whether a field carries anything at all. An empty array is nothing: ethers // reports an absent access list on a type 2 transaction as `[]`. function carriesValue(v) { if (!present(v)) return false; if (Array.isArray(v)) return v.length > 0; return true; } // Normalize a quantity that must be present, refusing anything that is not a // number: an approval carrying junk in a fee field cannot be compared, and an // uncomparable field is a refusal rather than a pass. function normalizeQuantity(v, label) { try { return BigInt(v); } catch { throw refuse( "The approved " + label + " is not a number, so it cannot be" + " compared with the signed transaction.", ); } } // Normalize a transaction value (hex string, decimal string, number or // bigint) to a bigint. An absent value is zero, matching ethers. The value is // page-controlled, so it goes through the same refusal as every other // quantity rather than throwing a raw BigInt conversion error. function normalizeValue(v) { if (!present(v)) return 0n; return normalizeQuantity(v, "value"); } // Normalize an access list to a comparable string. An absent or empty list is // the empty string, so absent and `[]` are the same thing. function normalizeAccessList(v) { if (!carriesValue(v)) return ""; let list; try { list = accessListify(v); } catch { throw refuse( "The approved access list is not a valid access list, so it cannot be compared with the signed transaction.", ); } return list .map( (entry) => String(entry.address).toLowerCase() + ":" + entry.storageKeys.map((k) => String(k).toLowerCase()).join(","), ) .join(";"); } // Normalize call data to a lowercase hex string. Absent data is "0x". function normalizeData(v) { if (v === null || v === undefined || v === "" || v === "0x") return "0x"; return String(v).toLowerCase(); } // How each field of an approved transaction is compared with the artifact. // There is an entry here for every field any allowed type serializes — a test // pins that against SERIALIZED_FIELDS — so the comparison loop covers the // whole of what gets signed and cannot silently skip a field for want of a // comparator. // // `kind` decides how the two sides are made comparable. A `quantity` must be // fixed by the approval: it is one of the numbers on the approval screen, and // an absent one means the artifact cannot be checked against what was // displayed. `to`, `value`, `data` and `accessList` have canonical absent // forms — contract creation, zero, "0x" and the empty list — so they are // normalized on both sides instead. const APPROVED_FIELDS = { chainId: { kind: "quantity", label: "network", message: "The signed transaction is for a different network than the one that was approved.", }, nonce: { kind: "quantity", label: "nonce", message: "The signed transaction does not carry the approved nonce.", }, gasLimit: { kind: "quantity", label: "gas limit", message: "The signed transaction does not carry the approved gas limit.", }, gasPrice: { kind: "quantity", label: "gas price", message: "The signed transaction does not carry the approved gas price.", }, maxFeePerGas: { kind: "quantity", label: "maximum fee per gas", message: "The signed transaction does not carry the approved maximum fee per gas.", }, maxPriorityFeePerGas: { kind: "quantity", label: "maximum priority fee per gas", message: "The signed transaction does not carry the approved maximum priority fee per gas.", }, to: { kind: "address", label: "recipient", message: "The signed transaction does not go to the approved recipient.", }, value: { kind: "value", label: "value", message: "The signed transaction does not carry the approved value.", }, data: { kind: "data", label: "call data", message: "The signed transaction does not carry the approved call data.", }, accessList: { kind: "accessList", label: "access list", message: "The signed transaction does not carry the approved access list.", }, }; // Compare one field of the artifact with the approved transaction. A field // with no entry in the table above is refused rather than skipped: the loop // below runs over the fields the type serializes, so an unmatched key means // something that gets signed has no comparator at all. function assertFieldMatches(key, parsed, approvedTx) { const field = APPROVED_FIELDS[key]; if (!field) { throw refuse( "The signed transaction carries a field this wallet cannot compare with the approval.", ); } switch (field.kind) { case "quantity": { if (!present(approvedTx[key])) { throw refuse( "The approved transaction fixes no " + field.label + ", so the signed transaction cannot be checked" + " against what was shown.", ); } const approved = normalizeQuantity(approvedTx[key], field.label); if (normalizeQuantity(parsed[key], field.label) !== approved) { throw refuse(field.message); } return; } case "address": if (!sameAddress(parsed[key], approvedTx[key])) { throw refuse(field.message); } return; case "value": if (normalizeValue(parsed[key]) !== normalizeValue(approvedTx[key])) throw refuse(field.message); return; case "data": if (normalizeData(parsed[key]) !== normalizeData(approvedTx[key])) throw refuse(field.message); return; default: if ( normalizeAccessList(parsed[key]) !== normalizeAccessList(approvedTx[key]) ) { throw refuse(field.message); } } } // The ceilings, applied to a transaction that is either about to be displayed // or about to be broadcast. See MAX_GAS_LIMIT above for what they are for: // they bound what the RPC node can talk this wallet into showing the user, // which is the one thing comparing the artifact with the screen cannot do. function assertWithinCeilings(tx) { if ( present(tx.gasLimit) && normalizeQuantity(tx.gasLimit, "gas limit") > MAX_GAS_LIMIT ) { throw refuse( "The signed transaction sets a gas limit no network this wallet supports can accept.", ); } for (const key of ["gasPrice", "maxFeePerGas", "maxPriorityFeePerGas"]) { if (!present(tx[key])) continue; if (normalizeQuantity(tx[key], "fee per gas") > MAX_FEE_PER_GAS) { throw refuse( "The signed transaction sets a fee per gas far above any plausible value.", ); } } } // Refuse a field only a transaction type this wallet does not sign can carry. // The type allowlist keeps these unreachable in production, which is exactly // what they are for; it also means nothing else exercises them, so this is // exported and tested on its own rather than left to be believed. function assertNoForbiddenFields(parsed) { for (const field of FORBIDDEN_FIELDS) { if (carriesValue(parsed[field.key])) throw refuse(field.message); } } // Closing structural check. Rebuild the transaction from the fields the // comparisons cover, and nothing else, then compare the unsigned bytes. Every // field carried by the artifact but absent from the rebuild changes the // serialization, so this refuses anything this module does not account for — // including a field a future ethers learns to parse onto an allowed type — // instead of waving it through by not naming it. Also exported for its own // test: nothing reachable today can make the bytes differ. function assertNothingUnchecked(parsed) { let rebuilt; try { const fields = { type: parsed.type }; for (const key of SERIALIZED_FIELDS[parsed.type]) { fields[key] = parsed[key]; } rebuilt = Transaction.from(fields); } catch { throw refuse( "The signed transaction could not be rebuilt from the fields that were checked, so it cannot be shown to be the approved transaction.", ); } if (rebuilt.unsignedSerialized !== parsed.unsignedSerialized) { throw refuse( "The signed transaction carries data beyond the fields that were checked against the approval.", ); } } // The other half of the closing check, and the one that makes it bind on the // bytes that actually leave: every comparison above runs against the decode, // so on its own the rebuild proves only that the transaction ethers understood // is the approved one. What the background hands to broadcastTransaction() is // the artifact string itself. Requiring the artifact to be exactly the // canonical re-encoding of its own decode closes the gap between the two — // no encoding the decoder normalizes away (a leading zero byte on an RLP // quantity, say) can differ from what was checked. Hex case is not part of the // encoding, so only that is normalized before comparing. function assertCanonicalBytes(parsed, rawSignedTx) { if (parsed.serialized !== String(rawSignedTx).toLowerCase()) { throw refuse( "The signed transaction is not encoded canonically, so the bytes that would be broadcast are not the bytes that were checked.", ); } } // Assert that a raw signed transaction is the transaction the user approved, // signed by the address the approval was raised for, on the network that is // selected. Returns the parsed ethers Transaction on success, throws // otherwise. // // `approvedTx` is the populated transaction the approval screen displayed, and // `expectedFrom` is the address that was active when the approval was raised — // not whichever address is active now. An address switch between approval and // signing therefore refuses here rather than producing a transaction from an // account the approval did not name. function verifySignedTx( rawSignedTx, approvedTx, expectedFrom, selectedChainId, ) { if (typeof rawSignedTx !== "string" || !rawSignedTx.startsWith("0x")) { throw refuse("The signed transaction is missing or malformed."); } // Nothing to compare against is a refusal like any other: an approval that // does not carry the transaction it displayed cannot vouch for one. if (!approvedTx || typeof approvedTx !== "object") { throw refuse( "There is no approved transaction to check the signed transaction against.", ); } let parsed; try { parsed = Transaction.from(rawSignedTx); } catch { throw refuse("The signed transaction could not be decoded."); } if (!parsed.from) { throw refuse("The signed transaction carries no valid signature."); } if (!sameAddress(parsed.from, expectedFrom)) { throw refuse( "The signed transaction was signed by a different address than the one that was approved.", ); } // Before any field is looked at: the type decides which fields exist at // all, so an unrecognised type is refused outright rather than compared // field by field against an approval that cannot describe it. if (!ALLOWED_TX_TYPES.includes(parsed.type)) { throw refuse( "The signed transaction is of a type this wallet does not sign, so what it would do beyond the approved transfer cannot be checked.", ); } assertNoForbiddenFields(parsed); // The selected network, not the artifact, is the authority on which chain // this may be broadcast to; without it nothing can be verified. if (!present(selectedChainId)) { throw refuse( "The selected network is unknown, so the signed transaction cannot be checked against it.", ); } if (parsed.chainId !== normalizeQuantity(selectedChainId, "network")) { throw refuse( "The signed transaction is for a different network than the one that is selected.", ); } // The approved fee mechanism, named before the type comparison below // subsumes it: the fee the user agreed to is only meaningful under the // mechanism it was quoted in, and saying so is more use than "a different // transaction type". const approvedEip1559 = present(approvedTx.maxFeePerGas) || present(approvedTx.maxPriorityFeePerGas); const approvedLegacy = present(approvedTx.gasPrice); const signedEip1559 = parsed.type === 2; if ( (approvedEip1559 && !signedEip1559) || (approvedLegacy && signedEip1559) ) { throw refuse( "The signed transaction does not use the approved fee mechanism.", ); } // The type decides which fields are compared, so it is compared first and // against the approval, not merely checked for membership of the // allowlist above. if (!present(approvedTx.type)) { throw refuse( "The approved transaction fixes no transaction type, so the signed transaction cannot be checked against what was shown.", ); } if ( BigInt(parsed.type) !== normalizeQuantity(approvedTx.type, "transaction type") ) { throw refuse( "The signed transaction does not use the approved transaction type.", ); } // Every field this type serializes, compared with the transaction the user // was shown. Driving the loop off SERIALIZED_FIELDS is what keeps this // exhaustive: the same table decides what assertNothingUnchecked() rebuilds // from, so a field that gets signed and is not compared here cannot exist. for (const key of SERIALIZED_FIELDS[parsed.type]) { assertFieldMatches(key, parsed, approvedTx); } assertWithinCeilings(parsed); assertNothingUnchecked(parsed); assertCanonicalBytes(parsed, rawSignedTx); return parsed; } // Assert that a signature over the approved message or typed data was // produced by the address the approval was raised for. Returns the recovered // address on success, throws otherwise. function verifySignature(signParams, signature, expectedFrom) { if (typeof signature !== "string" || !signature.startsWith("0x")) { throw refuse("The signature is missing or malformed."); } let recovered; try { if ( signParams.method === "personal_sign" || signParams.method === "eth_sign" ) { recovered = verifyMessage(getBytes(signParams.message), signature); } else { const typedData = JSON.parse(signParams.typedData); const { domain, types, message } = typedData; // ethers derives EIP712Domain itself and rejects it as an input. delete types.EIP712Domain; recovered = verifyTypedData(domain, types, message, signature); } } catch { throw refuse("The signature could not be verified."); } if (!sameAddress(recovered, expectedFrom)) { throw refuse( "The signature was produced by a different address than the one that was approved.", ); } return recovered; } // The stage a transaction approval failed at. Which stage it is decides // whether the approval survives the failure. const TX_STAGE_SIGN = "sign"; const TX_STAGE_VERIFY = "verify"; const TX_STAGE_BROADCAST = "broadcast"; // Not a failure of this request at all: a second response arrived for an // approval an attempt already holds. The first attempt is still running and // may yet succeed, so the one thing the popup must not say is "start again // from the site". const TX_STAGE_INFLIGHT = "inflight"; function errorText(err) { if (typeof err === "string" && err !== "") return err; if (err && (err.shortMessage || err.message)) { return err.shortMessage || err.message; } return "The transaction could not be sent."; } // What the background does with a pending transaction approval after a failed // attempt: what it tells the popup, and whether the approval is spent // (resolved to the requesting page as an error and deleted) or left standing // so the user can try the transaction they already saw again. // // - sign: the popup could not produce an artifact, almost always a wrong // password. Nothing left the extension, so the approval stands. // - verify: a mismatch is a refusal and spends the approval — an artifact // that is not the approved transaction must never be retried against that // approval. Anything else failed before the check ran and is retryable. // - broadcast: always terminal. A broadcast that throws after the node // accepted the transaction is routine (a timeout, a dropped response, a // node answering "already known"), so the wallet cannot tell a transaction // that never left from one that is already in the mempool. The approval is // spent and the requesting page has been given its outcome; a second // attempt against it would report a second outcome for one request. function describeTxFailure(stage, err) { const error = errorText(err); const retryable = stage === TX_STAGE_SIGN || (stage === TX_STAGE_VERIFY && failureIsRetryable(err)); return { error, retryable, spendApproval: !retryable }; } // What the popup shows and does after the background reports a failed signing // attempt. A retryable failure leaves the approval pending in the background, // so the button goes back to being usable; a refusal spent the approval, and // the popup says so rather than offering a button that cannot succeed. // // A failed broadcast gets its own wording: the transaction may already be on // the network, so telling the user to start again from the site is exactly the // wrong instruction. function describeSigningFailure(response, fallbackMessage) { let message = (response && response.error) || fallbackMessage; if (!/[.!?]$/.test(message)) message += "."; const retryable = !!(response && response.retryable); const stage = response && response.stage; if (!retryable) { if (stage === TX_STAGE_BROADCAST) { message += " The transaction may still have reached the network." + " Check the account before sending it again."; } else if (stage === TX_STAGE_INFLIGHT) { message += " The first attempt is still running and may still succeed." + " Wait for it rather than starting again."; } else { message += " This request can no longer be signed. Please start it" + " again from the site."; } } return { message, retryable }; } module.exports = { verifySignedTx, verifySignature, assertNoForbiddenFields, assertNothingUnchecked, assertCanonicalBytes, assertWithinCeilings, sameAddress, failureIsRetryable, describeTxFailure, describeSigningFailure, ApprovalMismatchError, ALLOWED_TX_TYPES, SERIALIZED_FIELDS, FORBIDDEN_FIELDS, APPROVED_FIELDS, TX_STAGE_SIGN, TX_STAGE_VERIFY, TX_STAGE_BROADCAST, TX_STAGE_INFLIGHT, MAX_GAS_LIMIT, MAX_FEE_PER_GAS, };