// 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. // // 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. // // Fields the approval does not carry are not treated as zero. The popup // populates nonce, gas limit, fee and chain id through populateTransaction() // when the requesting page did not fix them, so there is no approved value to // compare against; treating absent as zero would refuse every legitimate // transaction. Those fields are instead held to the absolute ceilings below, // and the chain id is always checked against the selected network rather than // against the approval alone, 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, getAddress, getBytes, verifyMessage, verifyTypedData, } = require("ethers"); // 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 !== ""; } // Normalize a transaction value (hex string, decimal string, number or // bigint) to a bigint. An absent value is zero, matching ethers. function normalizeValue(v) { if (!present(v)) return 0n; return BigInt(v); } // 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 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(); } // Quantity fields the requesting page may fix in the approval. Each is // compared exactly when the approval carries it, and left to the ceilings // above when it does not. const APPROVED_QUANTITIES = [ { key: "nonce", label: "nonce", message: "The signed transaction does not carry the approved nonce.", }, { key: "gasLimit", label: "gas limit", message: "The signed transaction does not carry the approved gas limit.", }, { key: "gasPrice", label: "gas price", message: "The signed transaction does not carry the approved gas price.", }, { key: "maxFeePerGas", label: "maximum fee per gas", message: "The signed transaction does not carry the approved maximum fee per gas.", }, { key: "maxPriorityFeePerGas", label: "maximum priority fee per gas", message: "The signed transaction does not carry the approved maximum priority fee per gas.", }, ]; // 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. function verifySignedTx(rawSignedTx, txParams, expectedFrom, selectedChainId) { if (typeof rawSignedTx !== "string" || !rawSignedTx.startsWith("0x")) { throw refuse("The signed transaction is missing or malformed."); } 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.", ); } // 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.", ); } if ( present(txParams.chainId) && parsed.chainId !== normalizeQuantity(txParams.chainId, "network") ) { throw refuse( "The signed transaction is for a different network than the one that was approved.", ); } if (!sameAddress(parsed.to, txParams.to)) { throw refuse( "The signed transaction does not go to the approved recipient.", ); } if (normalizeValue(parsed.value) !== normalizeValue(txParams.value)) { throw refuse( "The signed transaction does not carry the approved value.", ); } if (normalizeData(parsed.data) !== normalizeData(txParams.data)) { throw refuse( "The signed transaction does not carry the approved call data.", ); } // An approval that fixed EIP-1559 fees must not be signed as a legacy // transaction, and vice versa: the fee the user agreed to is only // meaningful under the mechanism it was quoted in. const approvedEip1559 = present(txParams.maxFeePerGas) || present(txParams.maxPriorityFeePerGas); const approvedLegacy = present(txParams.gasPrice); const signedEip1559 = parsed.type === 2 || parsed.type === 3; if ( (approvedEip1559 && !signedEip1559) || (approvedLegacy && signedEip1559) ) { throw refuse( "The signed transaction does not use the approved fee mechanism.", ); } for (const field of APPROVED_QUANTITIES) { if (!present(txParams[field.key])) continue; const approved = normalizeQuantity(txParams[field.key], field.label); if (normalizeQuantity(parsed[field.key], field.label) !== approved) { throw refuse(field.message); } } if (parsed.gasLimit > 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"]) { const fee = parsed[key]; if (fee !== null && fee !== undefined && fee > MAX_FEE_PER_GAS) { throw refuse( "The signed transaction sets a fee per gas far above any plausible value.", ); } } 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; } // 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. function describeSigningFailure(response, fallbackMessage) { let message = (response && response.error) || fallbackMessage; if (!/[.!?]$/.test(message)) message += "."; const retryable = !!(response && response.retryable); if (!retryable) { message += " This request can no longer be signed. Please start it again" + " from the site."; } return { message, retryable }; } module.exports = { verifySignedTx, verifySignature, sameAddress, failureIsRetryable, describeSigningFailure, ApprovalMismatchError, MAX_GAS_LIMIT, MAX_FEE_PER_GAS, };