All checks were successful
check / check (push) Successful in 32s
The signed artifact was compared with the dApp's request object, so every field the dApp left out — normally the nonce, the gas limit and every fee field, because populateTransaction() filled them in the popup — was checked by nothing but the absolute ceilings. A bare transfer at the fee ceiling hands the validator 2.1 ETH. The ceilings were never the defect: the thing being verified was not the thing the user approved. The transaction is now populated in the background, before the approval window opens, and that populated object is what is displayed, what the popup signs, and what the artifact is verified against. Every consequential field is compared exactly. - src/shared/approvalTx.js populates the request through a VoidSigner over the configured RPC and serializes the result to the fields its type serializes, as hex quantities that survive the JSON messaging boundary. Fields the wallet does not act on are dropped before ethers sees the page's object. - Population failure raises no approval and opens no window: the error goes back to the requesting page, bounded by a 20-second timeout. A half-initialised approval record would be exactly the state the settle interlock exists to keep out of that record, and the same estimate previously failed after the user had typed their password. - verifySignedTx compares the artifact field by field over SERIALIZED_FIELDS[type], plus the type itself. A quantity the approval does not fix is a refusal rather than a skipped comparison. The ceilings stay as a documented backstop and now also apply at population, where they bound what an RPC node can talk the wallet into displaying. - The approval pins the address it was raised for. Verification uses that address, not getActiveAddress(), and an address switch between approval and signing refuses rather than signing from an account the screen never named — including a switch during population, and on the message-signing path. A request naming an address that is not the active one is refused outright. - The approval screen shows the network, gas limit, fee per gas, maximum fee and nonce it now vouches for, and the popup signs the object it was given with no provider and no population of its own. The settle chokepoint is untouched: one delete of pendingApprovals and one approval.resolve(), both inside settleApproval(), the claim taken synchronously before the first await, and a refused settle still leaving the approval window standing.
692 lines
27 KiB
JavaScript
692 lines
27 KiB
JavaScript
// 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,
|
|
};
|