779 lines
31 KiB
JavaScript
779 lines
31 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";
|
|
// A transaction refused for a nonce that is already spoken for, either by the
|
|
// node's own answer or by this wallet's record of what it has broadcast. It is
|
|
// the one broadcast-stage failure that is not ambiguous: the transaction was
|
|
// not taken, so the user is told it did not reach the network and to send it
|
|
// again, rather than being warned that it might already be out there.
|
|
const TX_STAGE_NONCE = "nonce";
|
|
|
|
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.";
|
|
}
|
|
|
|
// Every string a failure might carry its reason in. ethers reports the node's
|
|
// own words in `shortMessage`, but a JSON-RPC error it could not classify is
|
|
// nested under `error` or `info.error` with the node's message intact, and the
|
|
// classification below has to see that too.
|
|
function failureTexts(err) {
|
|
if (typeof err === "string") return [err];
|
|
if (!err || typeof err !== "object") return [];
|
|
const texts = [];
|
|
for (const text of [err.shortMessage, err.message, err.reason]) {
|
|
if (text) texts.push(String(text));
|
|
}
|
|
const nested = err.error || (err.info && err.info.error);
|
|
if (nested && nested.message) texts.push(String(nested.message));
|
|
return texts;
|
|
}
|
|
|
|
// What the Ethereum clients say when a transaction's nonce is already spoken
|
|
// for: either it is below the account's next nonce, or another transaction is
|
|
// sitting in the pool at that nonce and this one did not outbid it. Either way
|
|
// the node answered, and its answer was that it did not take this transaction.
|
|
//
|
|
// "already known" is deliberately absent. A node that says it knows the
|
|
// transaction has it, so that transaction did reach the network and the
|
|
// ambiguous broadcast wording is the correct one for it.
|
|
const NONCE_COLLISION_PATTERNS = [
|
|
/nonce too low/i,
|
|
/nonce has already been used/i,
|
|
/invalid nonce/i,
|
|
/oldnonce/i,
|
|
/replacement transaction underpriced/i,
|
|
/replacement fee too low/i,
|
|
];
|
|
|
|
// ethers' own classification of the same two conditions.
|
|
const NONCE_COLLISION_CODES = ["NONCE_EXPIRED", "REPLACEMENT_UNDERPRICED"];
|
|
|
|
// Whether a failed send is a nonce collision.
|
|
function isNonceCollision(err) {
|
|
if (!err) return false;
|
|
if (err.code && NONCE_COLLISION_CODES.includes(err.code)) return true;
|
|
return failureTexts(err).some((text) =>
|
|
NONCE_COLLISION_PATTERNS.some((pattern) => pattern.test(text)),
|
|
);
|
|
}
|
|
|
|
// What both the requesting page and the popup are told about a nonce
|
|
// collision. The node's own words ("nonce too low") are a fragment and are
|
|
// replaced rather than passed through: they are not a sentence, and they say
|
|
// less than the wallet knows.
|
|
const NONCE_COLLISION_MESSAGE =
|
|
"The transaction was not sent, because its nonce had already been used" +
|
|
" by another transaction.";
|
|
|
|
// 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.
|
|
// - nonce: terminal too, and the one case where the wallet does know the
|
|
// transaction never left. The approval carries a nonce that is spent, so
|
|
// the artifact signed against it can never be accepted and the user is told
|
|
// to send it again from the site.
|
|
//
|
|
// The stage comes back out because a broadcast failure the node blamed on the
|
|
// nonce is reclassified here; the caller reports the stage this returns rather
|
|
// than the one it passed in.
|
|
function describeTxFailure(stage, err) {
|
|
if (
|
|
stage === TX_STAGE_NONCE ||
|
|
(stage === TX_STAGE_BROADCAST && isNonceCollision(err))
|
|
) {
|
|
return {
|
|
error: NONCE_COLLISION_MESSAGE,
|
|
retryable: false,
|
|
spendApproval: true,
|
|
stage: TX_STAGE_NONCE,
|
|
};
|
|
}
|
|
const error = errorText(err);
|
|
const retryable =
|
|
stage === TX_STAGE_SIGN ||
|
|
(stage === TX_STAGE_VERIFY && failureIsRetryable(err));
|
|
return { error, retryable, spendApproval: !retryable, stage };
|
|
}
|
|
|
|
// 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. A nonce collision is the exception to that exception —
|
|
// the transaction demonstrably did not go out, and saying it might have would
|
|
// send the user hunting for a transaction that does not exist.
|
|
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_NONCE) {
|
|
message +=
|
|
" The transaction did not reach the network." +
|
|
" Please send it again from the site.";
|
|
} else 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,
|
|
isNonceCollision,
|
|
describeTxFailure,
|
|
describeSigningFailure,
|
|
ApprovalMismatchError,
|
|
NONCE_COLLISION_MESSAGE,
|
|
ALLOWED_TX_TYPES,
|
|
SERIALIZED_FIELDS,
|
|
FORBIDDEN_FIELDS,
|
|
APPROVED_FIELDS,
|
|
TX_STAGE_SIGN,
|
|
TX_STAGE_VERIFY,
|
|
TX_STAGE_BROADCAST,
|
|
TX_STAGE_INFLIGHT,
|
|
TX_STAGE_NONCE,
|
|
MAX_GAS_LIMIT,
|
|
MAX_FEE_PER_GAS,
|
|
};
|