harden: verify the signed transaction against what the popup displayed (closes #216)
Some checks failed
check / check (push) Has been cancelled
Some checks failed
check / check (push) Has been cancelled
Verification compared the signed artifact against the dApp's request object. For every field the dApp omitted -- normally nonce, gas limit and all the fee fields, since the popup filled them in -- the number the user actually read on screen was verified by nothing, and only absolute ceilings stood behind it. The transaction is now populated in the background before the approval window opens, and that populated object is both what the popup displays and what the signed artifact is verified against. Every consequential field becomes an equality comparison; the ceilings remain as a backstop. Population failing means no approval and no window, and the error goes to the requesting page -- earlier than before, where the same estimate failed after the password had been typed. The account is pinned too: `from` is compared against the address named at approval time rather than whichever address is active at signing, so switching accounts mid-flow refuses instead of signing from an account the approval did not name. The message-signing path had the same defect and gets the same fix. Nonce selection moves earlier as a consequence; the concurrent-approval case that follows from it is tracked at #271.
This commit was merged in pull request #269.
This commit is contained in:
@@ -11,6 +11,7 @@ const {
|
||||
assertNoForbiddenFields,
|
||||
assertNothingUnchecked,
|
||||
assertCanonicalBytes,
|
||||
assertWithinCeilings,
|
||||
sameAddress,
|
||||
failureIsRetryable,
|
||||
describeTxFailure,
|
||||
@@ -18,12 +19,14 @@ const {
|
||||
ALLOWED_TX_TYPES,
|
||||
SERIALIZED_FIELDS,
|
||||
FORBIDDEN_FIELDS,
|
||||
APPROVED_FIELDS,
|
||||
TX_STAGE_SIGN,
|
||||
TX_STAGE_VERIFY,
|
||||
TX_STAGE_BROADCAST,
|
||||
MAX_GAS_LIMIT,
|
||||
MAX_FEE_PER_GAS,
|
||||
} = require("../src/shared/approvalVerify");
|
||||
const { prepareApprovalTx } = require("../src/shared/approvalTx");
|
||||
const { getSignerForAddress } = require("../src/shared/wallet");
|
||||
|
||||
// Fixed test keys — never used for anything but these tests.
|
||||
@@ -42,7 +45,10 @@ const OTHER_RECIPIENT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||
const SELECTED = "0x1";
|
||||
const SEPOLIA = "0xaa36a7";
|
||||
|
||||
// Approved parameters as a dApp would supply them over eth_sendTransaction.
|
||||
// Parameters as a dApp would supply them over eth_sendTransaction. Note what
|
||||
// is missing: nonce, gas limit and fees. The background fills those in before
|
||||
// the approval screen is drawn, which is why the approval below and not this
|
||||
// object is what every comparison runs against.
|
||||
const TX_PARAMS = {
|
||||
from: signer.address,
|
||||
to: RECIPIENT,
|
||||
@@ -61,8 +67,8 @@ const POPULATED = {
|
||||
type: 2,
|
||||
};
|
||||
|
||||
// Build a signable transaction from approved params. The popup does the same
|
||||
// thing through populateTransaction(); here the fields are fixed so the test
|
||||
// Build a signable transaction from a request. The background populates the
|
||||
// same fields through populateTransaction(); here they are fixed so the test
|
||||
// needs no provider. `overrides` stands in for what a tampered or misbuilt
|
||||
// popup would put on the wire.
|
||||
function txFor(params, overrides) {
|
||||
@@ -75,6 +81,21 @@ function txFor(params, overrides) {
|
||||
};
|
||||
}
|
||||
|
||||
// The populated transaction the approval screen displayed, which is the object
|
||||
// the artifact is verified against. Built from the same fields as the signable
|
||||
// transaction above, because that is the point: displayed and verified are one
|
||||
// object.
|
||||
function approvedFor(params, overrides) {
|
||||
return {
|
||||
from: signer.address,
|
||||
accessList: [],
|
||||
...txFor(params, overrides),
|
||||
};
|
||||
}
|
||||
|
||||
// The ordinary case: the dApp's request, populated.
|
||||
const APPROVED = approvedFor(TX_PARAMS);
|
||||
|
||||
async function signedFor(params, withWallet, overrides) {
|
||||
return (withWallet || signer).signTransaction(txFor(params, overrides));
|
||||
}
|
||||
@@ -108,7 +129,7 @@ describe("sameAddress", () => {
|
||||
describe("verifySignedTx", () => {
|
||||
test("accepts the approved transaction signed by the approved address", async () => {
|
||||
const raw = await signedFor(TX_PARAMS);
|
||||
const parsed = verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
|
||||
const parsed = verifySignedTx(raw, APPROVED, signer.address, SELECTED);
|
||||
expect(parsed.from).toBe(signer.address);
|
||||
expect(parsed.hash).toBe(Transaction.from(raw).hash);
|
||||
});
|
||||
@@ -117,23 +138,23 @@ describe("verifySignedTx", () => {
|
||||
const params = { to: undefined, value: "0x0", data: "0x600160005500" };
|
||||
const raw = await signedFor(params);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, params, signer.address, SELECTED),
|
||||
verifySignedTx(raw, approvedFor(params), signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts an absent value as zero", async () => {
|
||||
const approved = { to: RECIPIENT, data: "0x" };
|
||||
const raw = await signedFor(approved);
|
||||
const params = { to: RECIPIENT, data: "0x" };
|
||||
const raw = await signedFor(params);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
verifySignedTx(raw, approvedFor(params), signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts call data whose case differs from the approval", async () => {
|
||||
const approved = { to: RECIPIENT, value: "0x0", data: "0xDEADBEEF" };
|
||||
const raw = await signedFor(approved);
|
||||
const params = { to: RECIPIENT, value: "0x0", data: "0xDEADBEEF" };
|
||||
const raw = await signedFor(params);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
verifySignedTx(raw, approvedFor(params), signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -143,7 +164,7 @@ describe("verifySignedTx", () => {
|
||||
to: OTHER_RECIPIENT,
|
||||
});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/approved recipient/);
|
||||
});
|
||||
|
||||
@@ -153,47 +174,64 @@ describe("verifySignedTx", () => {
|
||||
value: "0x4563918244f40000",
|
||||
});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/approved value/);
|
||||
});
|
||||
|
||||
test("rejects substituted call data", async () => {
|
||||
const raw = await signedFor({ ...TX_PARAMS, data: "0xc0ffee" });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/approved call data/);
|
||||
});
|
||||
|
||||
test("rejects a transaction signed by a different address", async () => {
|
||||
const raw = await signedFor(TX_PARAMS, other);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/different address/);
|
||||
});
|
||||
|
||||
// The address the approval named, not whichever address is active when the
|
||||
// artifact comes back: an approval raised for one account cannot be
|
||||
// satisfied by a signature from another, whatever the wallet switched to
|
||||
// in between.
|
||||
test("rejects a signature from the address that is active now", async () => {
|
||||
const raw = await signedFor(TX_PARAMS, other);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/different address than the one that was approved/);
|
||||
// The same artifact against the same approval, verified for the other
|
||||
// address, is what would have happened had expectedFrom been read from
|
||||
// the wallet's current state.
|
||||
expect(() =>
|
||||
verifySignedTx(raw, APPROVED, other.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("rejects an unsigned transaction", () => {
|
||||
const unsigned = Transaction.from(txFor(TX_PARAMS)).unsignedSerialized;
|
||||
expect(() =>
|
||||
verifySignedTx(unsigned, TX_PARAMS, signer.address, SELECTED),
|
||||
verifySignedTx(unsigned, APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/no valid signature/);
|
||||
});
|
||||
|
||||
test("rejects a missing or malformed payload", () => {
|
||||
expect(() =>
|
||||
verifySignedTx(undefined, TX_PARAMS, signer.address, SELECTED),
|
||||
verifySignedTx(undefined, APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/missing or malformed/);
|
||||
expect(() =>
|
||||
verifySignedTx("nope", TX_PARAMS, signer.address, SELECTED),
|
||||
verifySignedTx("nope", APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/missing or malformed/);
|
||||
expect(() =>
|
||||
verifySignedTx("0xc0ffee", TX_PARAMS, signer.address, SELECTED),
|
||||
verifySignedTx("0xc0ffee", APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/could not be decoded/);
|
||||
});
|
||||
|
||||
test("every rejection message is a full sentence", async () => {
|
||||
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
|
||||
try {
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
|
||||
verifySignedTx(raw, APPROVED, signer.address, SELECTED);
|
||||
throw new Error("expected a rejection");
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch(/^[A-Z].*\.$/);
|
||||
@@ -201,20 +239,113 @@ describe("verifySignedTx", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The defect this file's approvals now stand against: for every field the dApp
|
||||
// left out, the old comparison had nothing to compare and skipped the field,
|
||||
// so the fee and the nonce the user read off the screen were checked by the
|
||||
// ceilings alone. A populated approval fixes all of them, and an approval that
|
||||
// does not fix one is a refusal rather than a pass.
|
||||
describe("verifySignedTx against what was displayed", () => {
|
||||
test("a fee differing from the displayed one is refused", async () => {
|
||||
// Ten times the fee the screen showed, and far below the ceiling: the
|
||||
// artifact the old comparison would have accepted.
|
||||
const inflated = 20000000000n;
|
||||
expect(inflated).toBeLessThan(MAX_FEE_PER_GAS);
|
||||
const raw = await signedWith({ maxFeePerGas: inflated });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/approved maximum fee per gas/);
|
||||
});
|
||||
|
||||
test("a nonce differing from the displayed one is refused", async () => {
|
||||
const raw = await signedWith({ nonce: 8 });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/approved nonce/);
|
||||
});
|
||||
|
||||
test("a gas limit differing from the displayed one is refused", async () => {
|
||||
const raw = await signedWith({ gasLimit: 250000n });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/approved gas limit/);
|
||||
});
|
||||
|
||||
test("an approval fixing no quantity is refused, not waved through", async () => {
|
||||
const raw = await signedWith({});
|
||||
for (const key of [
|
||||
"chainId",
|
||||
"nonce",
|
||||
"gasLimit",
|
||||
"maxFeePerGas",
|
||||
"maxPriorityFeePerGas",
|
||||
]) {
|
||||
const incomplete = { ...APPROVED };
|
||||
delete incomplete[key];
|
||||
let thrown;
|
||||
try {
|
||||
verifySignedTx(raw, incomplete, signer.address, SELECTED);
|
||||
throw new Error("expected a rejection for " + key);
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown.message).toMatch(/fixes no /);
|
||||
expect(thrown.approvalMismatch).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("no approved transaction at all is refused", async () => {
|
||||
const raw = await signedWith({});
|
||||
for (const approved of [undefined, null, "0xdeadbeef"]) {
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/no approved transaction/);
|
||||
}
|
||||
});
|
||||
|
||||
test("an approval fixing no transaction type is refused", async () => {
|
||||
const raw = await signedWith({});
|
||||
const incomplete = { ...APPROVED };
|
||||
delete incomplete.type;
|
||||
expect(() =>
|
||||
verifySignedTx(raw, incomplete, signer.address, SELECTED),
|
||||
).toThrow(/fixes no transaction type/);
|
||||
});
|
||||
|
||||
test("an artifact of a type other than the approved one is refused", async () => {
|
||||
// Same fee mechanism on both sides, so only the type differs: a type 1
|
||||
// artifact against a type 2 approval.
|
||||
const approved = approvedFor(TX_PARAMS, {
|
||||
type: 1,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
});
|
||||
const raw = await signedWith({
|
||||
type: 0,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/approved transaction type/);
|
||||
});
|
||||
});
|
||||
|
||||
// One case per consequential field: the field alone differs from what was
|
||||
// approved, and that alone must refuse the signature.
|
||||
describe("verifySignedTx field comparison", () => {
|
||||
test("rejects a chain id that is not the selected network", async () => {
|
||||
const raw = await signedWith({ chainId: 11155111 });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/different network than the one that is selected/);
|
||||
});
|
||||
|
||||
test("rejects a chain id that is not the approved one", async () => {
|
||||
// Selected network and signed chain id agree; the dApp asked for a
|
||||
// different chain, so the artifact is not what was approved.
|
||||
const approved = { ...TX_PARAMS, chainId: SEPOLIA };
|
||||
// Selected network and signed chain id agree; the approval was raised
|
||||
// for a different chain, so the artifact is not what was approved.
|
||||
const approved = { ...APPROVED, chainId: SEPOLIA };
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
@@ -224,58 +355,59 @@ describe("verifySignedTx field comparison", () => {
|
||||
test("refuses when the selected network is unknown", async () => {
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, undefined),
|
||||
verifySignedTx(raw, APPROVED, signer.address, undefined),
|
||||
).toThrow(/selected network is unknown/);
|
||||
});
|
||||
|
||||
test("rejects a substituted nonce", async () => {
|
||||
const approved = { ...TX_PARAMS, nonce: 7 };
|
||||
const raw = await signedWith({ nonce: 8 });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/approved nonce/);
|
||||
});
|
||||
|
||||
test("rejects a substituted gas limit", async () => {
|
||||
const approved = { ...TX_PARAMS, gasLimit: "0x186a0" };
|
||||
const raw = await signedWith({ gasLimit: 250000n });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/approved gas limit/);
|
||||
});
|
||||
|
||||
test("rejects a substituted maximum fee per gas", async () => {
|
||||
const approved = { ...TX_PARAMS, maxFeePerGas: "0x77359400" };
|
||||
const raw = await signedWith({ maxFeePerGas: 900000000000n });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/approved maximum fee per gas/);
|
||||
});
|
||||
|
||||
test("rejects a substituted maximum priority fee per gas", async () => {
|
||||
const approved = { ...TX_PARAMS, maxPriorityFeePerGas: "0x3b9aca00" };
|
||||
const raw = await signedWith({ maxPriorityFeePerGas: 1500000000n });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/approved maximum priority fee per gas/);
|
||||
});
|
||||
|
||||
test("rejects a substituted legacy gas price", async () => {
|
||||
const approved = { ...TX_PARAMS, gasPrice: "0x77359400" };
|
||||
const legacy = {
|
||||
type: 0,
|
||||
gasPrice: 9000000000n,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
};
|
||||
const raw = await signedWith(legacy);
|
||||
const approved = approvedFor(TX_PARAMS, legacy);
|
||||
const raw = await signedWith({ ...legacy, gasPrice: 9000000000n });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/approved gas price/);
|
||||
});
|
||||
|
||||
test("rejects an approved legacy fee signed as an EIP-1559 fee", async () => {
|
||||
const approved = { ...TX_PARAMS, gasPrice: "0x77359400" };
|
||||
const approved = approvedFor(TX_PARAMS, {
|
||||
type: 0,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
});
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
@@ -283,7 +415,6 @@ describe("verifySignedTx field comparison", () => {
|
||||
});
|
||||
|
||||
test("rejects an approved EIP-1559 fee signed as a legacy fee", async () => {
|
||||
const approved = { ...TX_PARAMS, maxFeePerGas: "0x77359400" };
|
||||
const raw = await signedWith({
|
||||
type: 0,
|
||||
gasPrice: 2000000000n,
|
||||
@@ -291,36 +422,72 @@ describe("verifySignedTx field comparison", () => {
|
||||
maxPriorityFeePerGas: null,
|
||||
});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/approved fee mechanism/);
|
||||
});
|
||||
|
||||
// The ceilings are a backstop against what the RPC node can talk the
|
||||
// wallet into populating and displaying, so they are checked against an
|
||||
// approval that carries the absurd value too — equality alone would accept
|
||||
// it, which is exactly what the ceiling is there for.
|
||||
test("rejects a gas limit above anything a supported network accepts", async () => {
|
||||
const raw = await signedWith({ gasLimit: MAX_GAS_LIMIT + 1n });
|
||||
const overrides = { gasLimit: MAX_GAS_LIMIT + 1n };
|
||||
const raw = await signedWith(overrides);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
verifySignedTx(
|
||||
raw,
|
||||
approvedFor(TX_PARAMS, overrides),
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
).toThrow(/gas limit no network this wallet supports/);
|
||||
});
|
||||
|
||||
test("rejects an absurd fee per gas the approval never fixed", async () => {
|
||||
const raw = await signedWith({
|
||||
test("rejects an absurd fee per gas even when it was displayed", async () => {
|
||||
const overrides = {
|
||||
maxFeePerGas: MAX_FEE_PER_GAS + 1n,
|
||||
maxPriorityFeePerGas: MAX_FEE_PER_GAS + 1n,
|
||||
});
|
||||
};
|
||||
const raw = await signedWith(overrides);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
verifySignedTx(
|
||||
raw,
|
||||
approvedFor(TX_PARAMS, overrides),
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
).toThrow(/fee per gas far above any plausible value/);
|
||||
});
|
||||
|
||||
test("assertWithinCeilings is the same check on either side of the screen", () => {
|
||||
expect(() =>
|
||||
assertWithinCeilings({ gasLimit: MAX_GAS_LIMIT + 1n }),
|
||||
).toThrow(/gas limit no network this wallet supports/);
|
||||
for (const key of [
|
||||
"gasPrice",
|
||||
"maxFeePerGas",
|
||||
"maxPriorityFeePerGas",
|
||||
]) {
|
||||
expect(() =>
|
||||
assertWithinCeilings({ [key]: MAX_FEE_PER_GAS + 1n }),
|
||||
).toThrow(/fee per gas far above any plausible value/);
|
||||
}
|
||||
expect(() =>
|
||||
assertWithinCeilings({
|
||||
gasLimit: MAX_GAS_LIMIT,
|
||||
maxFeePerGas: MAX_FEE_PER_GAS,
|
||||
maxPriorityFeePerGas: MAX_FEE_PER_GAS,
|
||||
}),
|
||||
).not.toThrow();
|
||||
// Nothing to bound is not a failure: a type 2 approval carries no gas
|
||||
// price, and a bare object must not be refused for lacking one.
|
||||
expect(() => assertWithinCeilings({})).not.toThrow();
|
||||
});
|
||||
|
||||
test("every field mismatch is a refusal, not a warning", async () => {
|
||||
const raw = await signedWith({ nonce: 8 });
|
||||
try {
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, nonce: 7 },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
);
|
||||
verifySignedTx(raw, APPROVED, signer.address, SELECTED);
|
||||
throw new Error("expected a rejection");
|
||||
} catch (e) {
|
||||
expect(e.approvalMismatch).toBe(true);
|
||||
@@ -331,17 +498,17 @@ describe("verifySignedTx field comparison", () => {
|
||||
|
||||
// The transaction type decides which fields exist, so an artifact of a type
|
||||
// this wallet does not sign carries consequences the approval cannot describe
|
||||
// and none of the field comparisons can see. The approval used here is the
|
||||
// ordinary dApp shape with no fee fields — the common case, since
|
||||
// populateTransaction() fills them — which is exactly the case the
|
||||
// fee-mechanism check cannot catch by accident.
|
||||
// and none of the field comparisons can see. The refusal has to come from the
|
||||
// type allowlist rather than from a field comparison, so these run against an
|
||||
// approval whose every other field matches the artifact exactly.
|
||||
describe("verifySignedTx transaction type", () => {
|
||||
const BARE_APPROVAL = {
|
||||
const BARE_REQUEST = {
|
||||
from: signer.address,
|
||||
to: RECIPIENT,
|
||||
value: "0x2386f26fc10000",
|
||||
data: "0x",
|
||||
};
|
||||
const BARE_APPROVAL = approvedFor(BARE_REQUEST);
|
||||
|
||||
// An EIP-7702 artifact that pays the approved amount to the approved
|
||||
// recipient and, in the same transaction, installs the attacker's code at
|
||||
@@ -353,7 +520,7 @@ describe("verifySignedTx transaction type", () => {
|
||||
chainId: 1,
|
||||
nonce: 8,
|
||||
});
|
||||
const raw = await signedFor(BARE_APPROVAL, signer, {
|
||||
const raw = await signedFor(BARE_REQUEST, signer, {
|
||||
type: 4,
|
||||
authorizationList: [authorization],
|
||||
});
|
||||
@@ -366,7 +533,7 @@ describe("verifySignedTx transaction type", () => {
|
||||
});
|
||||
|
||||
test("refuses a type 3 blob artifact", async () => {
|
||||
const raw = await signedFor(BARE_APPROVAL, signer, {
|
||||
const raw = await signedFor(BARE_REQUEST, signer, {
|
||||
type: 3,
|
||||
maxFeePerBlobGas: 1000000000n,
|
||||
blobVersionedHashes: ["0x01" + "ab".repeat(31)],
|
||||
@@ -390,7 +557,7 @@ describe("verifySignedTx transaction type", () => {
|
||||
chainId: 1,
|
||||
nonce: 8,
|
||||
});
|
||||
const raw = await signedFor(BARE_APPROVAL, signer, {
|
||||
const raw = await signedFor(BARE_REQUEST, signer, {
|
||||
type: 4,
|
||||
authorizationList: [authorization],
|
||||
});
|
||||
@@ -404,40 +571,45 @@ describe("verifySignedTx transaction type", () => {
|
||||
});
|
||||
|
||||
test("accepts a legacy type 0 transaction", async () => {
|
||||
const approved = { ...BARE_APPROVAL, gasPrice: "0x77359400" };
|
||||
const raw = await signedFor(approved, signer, {
|
||||
const legacy = {
|
||||
type: 0,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
});
|
||||
};
|
||||
const raw = await signedFor(BARE_REQUEST, signer, legacy);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
verifySignedTx(
|
||||
raw,
|
||||
approvedFor(BARE_REQUEST, legacy),
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts a type 1 transaction whose access list is the approved one", async () => {
|
||||
const accessList = [{ address: OTHER_RECIPIENT, storageKeys: [] }];
|
||||
const approved = {
|
||||
...BARE_APPROVAL,
|
||||
gasPrice: "0x77359400",
|
||||
accessList,
|
||||
};
|
||||
const raw = await signedFor(approved, signer, {
|
||||
const overrides = {
|
||||
type: 1,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
accessList,
|
||||
});
|
||||
accessList: [{ address: OTHER_RECIPIENT, storageKeys: [] }],
|
||||
};
|
||||
const raw = await signedFor(BARE_REQUEST, signer, overrides);
|
||||
expect(Transaction.from(raw).type).toBe(1);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
verifySignedTx(
|
||||
raw,
|
||||
approvedFor(BARE_REQUEST, overrides),
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("refuses an access list the approval never carried", async () => {
|
||||
const raw = await signedFor(BARE_APPROVAL, signer, {
|
||||
const raw = await signedFor(BARE_REQUEST, signer, {
|
||||
accessList: [{ address: OTHER_RECIPIENT, storageKeys: [] }],
|
||||
});
|
||||
expect(() =>
|
||||
@@ -446,8 +618,11 @@ describe("verifySignedTx transaction type", () => {
|
||||
});
|
||||
|
||||
test("treats an absent access list and an empty one as the same thing", async () => {
|
||||
const approved = { ...BARE_APPROVAL, accessList: [] };
|
||||
const raw = await signedFor(BARE_APPROVAL, signer, {});
|
||||
const approved = { ...BARE_APPROVAL };
|
||||
delete approved.accessList;
|
||||
expect(approved.accessList).toBeUndefined();
|
||||
const raw = await signedFor(BARE_REQUEST, signer, {});
|
||||
expect(Transaction.from(raw).accessList).toEqual([]);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
@@ -498,6 +673,25 @@ describe("verifySignedTx exhaustiveness", () => {
|
||||
expect(exposed.filter((name) => !accounted.has(name))).toEqual([]);
|
||||
});
|
||||
|
||||
// The comparison loop runs over the fields a type serializes and refuses a
|
||||
// field it has no comparator for. That refusal is unreachable only while
|
||||
// the table covers the whole of SERIALIZED_FIELDS, so the coverage is
|
||||
// pinned here rather than assumed: adding a field to a type without a
|
||||
// comparator would otherwise turn every transaction of that type into a
|
||||
// refusal, and adding a comparator without the field would be a check that
|
||||
// never runs.
|
||||
test("every field a type serializes has a comparator", () => {
|
||||
const serialized = new Set(
|
||||
Object.values(SERIALIZED_FIELDS).flat().sort(),
|
||||
);
|
||||
expect([...serialized].filter((key) => !APPROVED_FIELDS[key])).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
Object.keys(APPROVED_FIELDS).filter((key) => !serialized.has(key)),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
// The two layers behind the type allowlist. Nothing reachable through
|
||||
// verifySignedTx can trip either of them while the allowlist holds — that
|
||||
// is what they are for — so they are exercised directly rather than taken
|
||||
@@ -553,49 +747,30 @@ describe("verifySignedTx exhaustiveness", () => {
|
||||
test("an accepted artifact of each allowed type rebuilds byte for byte", async () => {
|
||||
const shapes = [
|
||||
{
|
||||
approved: { ...TX_PARAMS, gasPrice: "0x77359400" },
|
||||
overrides: {
|
||||
type: 0,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
},
|
||||
type: 0,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
},
|
||||
{
|
||||
approved: {
|
||||
...TX_PARAMS,
|
||||
gasPrice: "0x77359400",
|
||||
accessList: [
|
||||
{
|
||||
address: RECIPIENT,
|
||||
storageKeys: ["0x" + "11".repeat(32)],
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: {
|
||||
type: 1,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
accessList: [
|
||||
{
|
||||
address: RECIPIENT,
|
||||
storageKeys: ["0x" + "11".repeat(32)],
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 1,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
accessList: [
|
||||
{
|
||||
address: RECIPIENT,
|
||||
storageKeys: ["0x" + "11".repeat(32)],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ approved: TX_PARAMS, overrides: {} },
|
||||
{},
|
||||
];
|
||||
for (const shape of shapes) {
|
||||
const raw = await signedFor(
|
||||
shape.approved,
|
||||
signer,
|
||||
shape.overrides,
|
||||
);
|
||||
for (const overrides of shapes) {
|
||||
const raw = await signedFor(TX_PARAMS, signer, overrides);
|
||||
const parsed = verifySignedTx(
|
||||
raw,
|
||||
shape.approved,
|
||||
approvedFor(TX_PARAMS, overrides),
|
||||
signer.address,
|
||||
SELECTED,
|
||||
);
|
||||
@@ -644,7 +819,7 @@ describe("verifySignedTx canonical encoding", () => {
|
||||
test("refuses an artifact that is not its own canonical encoding", async () => {
|
||||
const mutated = await nonCanonical();
|
||||
expect(() =>
|
||||
verifySignedTx(mutated, TX_PARAMS, signer.address, SELECTED),
|
||||
verifySignedTx(mutated, APPROVED, signer.address, SELECTED),
|
||||
).toThrow(/not encoded canonically/);
|
||||
});
|
||||
|
||||
@@ -659,7 +834,7 @@ describe("verifySignedTx canonical encoding", () => {
|
||||
const raw = await signedWith({});
|
||||
const upper = "0x" + raw.slice(2).toUpperCase();
|
||||
expect(() =>
|
||||
verifySignedTx(upper, TX_PARAMS, signer.address, SELECTED),
|
||||
verifySignedTx(upper, APPROVED, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -670,46 +845,33 @@ describe("verifySignedTx normalization", () => {
|
||||
test("accepts a decimal chain id against a hex selected network", async () => {
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, 1),
|
||||
verifySignedTx(raw, APPROVED, signer.address, 1),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, "1"),
|
||||
verifySignedTx(raw, APPROVED, signer.address, "1"),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts an approved chain id written in hex", async () => {
|
||||
// The approved transaction crosses to the popup as JSON, so it comes back
|
||||
// spelled in hex quantities rather than in the bigints it was populated
|
||||
// with. None of that is tampering.
|
||||
test("accepts an approval spelled as the wire spells it", async () => {
|
||||
const raw = await signedWith({});
|
||||
const approved = { ...TX_PARAMS, chainId: "0x1" };
|
||||
const wire = {
|
||||
...APPROVED,
|
||||
chainId: "0x1",
|
||||
nonce: "0x7",
|
||||
gasLimit: "0x186a0",
|
||||
maxFeePerGas: "0x77359400",
|
||||
maxPriorityFeePerGas: "0x3b9aca00",
|
||||
value: "0x2386f26fc10000",
|
||||
};
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
verifySignedTx(raw, wire, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts a hex nonce against a numeric one", async () => {
|
||||
const raw = await signedWith({ nonce: 7 });
|
||||
expect(() =>
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, nonce: "0x7" },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts a decimal gas limit against a hex one", async () => {
|
||||
const raw = await signedWith({ gasLimit: 100000n });
|
||||
expect(() =>
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, gasLimit: "100000" },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts fee fields spelled as hex, decimal, number and bigint", async () => {
|
||||
test("accepts quantities spelled as hex, decimal, number and bigint", async () => {
|
||||
const raw = await signedWith({});
|
||||
for (const maxFee of [
|
||||
"0x77359400",
|
||||
@@ -720,7 +882,7 @@ describe("verifySignedTx normalization", () => {
|
||||
expect(() =>
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, maxFeePerGas: maxFee },
|
||||
{ ...APPROVED, maxFeePerGas: maxFee },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
@@ -728,24 +890,19 @@ describe("verifySignedTx normalization", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts an approval that fixes no nonce, gas or fee at all", async () => {
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts an approval whose recipient case differs", async () => {
|
||||
const raw = await signedWith({});
|
||||
const approved = { ...TX_PARAMS, to: RECIPIENT.toLowerCase() };
|
||||
const approved = { ...APPROVED, to: RECIPIENT.toLowerCase() };
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts absent call data against 0x", async () => {
|
||||
const approved = { to: RECIPIENT, value: "0x0" };
|
||||
const raw = await signedFor({ ...approved, data: "0x" });
|
||||
const params = { to: RECIPIENT, value: "0x0" };
|
||||
const approved = approvedFor(params);
|
||||
delete approved.data;
|
||||
const raw = await signedFor({ ...params, data: "0x" });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
@@ -756,7 +913,7 @@ describe("verifySignedTx normalization", () => {
|
||||
expect(() =>
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, maxFeePerGas: "cheap" },
|
||||
{ ...APPROVED, maxFeePerGas: "cheap" },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
@@ -774,7 +931,7 @@ describe("verifySignedTx normalization", () => {
|
||||
try {
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, value },
|
||||
{ ...APPROVED, value },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
);
|
||||
@@ -793,7 +950,7 @@ describe("verifySignedTx normalization", () => {
|
||||
expect(() =>
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, accessList: ["nope"] },
|
||||
{ ...APPROVED, accessList: ["nope"] },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
@@ -934,7 +1091,7 @@ describe("signing failure and retry", () => {
|
||||
test("a mismatch spends the approval", async () => {
|
||||
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
|
||||
try {
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
|
||||
verifySignedTx(raw, APPROVED, signer.address, SELECTED);
|
||||
throw new Error("expected a rejection");
|
||||
} catch (e) {
|
||||
expect(failureIsRetryable(e)).toBe(false);
|
||||
@@ -1007,7 +1164,7 @@ describe("signing failure and retry", () => {
|
||||
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
|
||||
let outcome;
|
||||
try {
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
|
||||
verifySignedTx(raw, APPROVED, signer.address, SELECTED);
|
||||
} catch (e) {
|
||||
outcome = describeTxFailure(TX_STAGE_VERIFY, e);
|
||||
}
|
||||
@@ -1061,12 +1218,13 @@ describe("signing failure and retry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// End-to-end over the messaging boundary, without a browser: run the exact
|
||||
// sequence the approval popup runs, then hand the artifact to the exact check
|
||||
// the background runs before it broadcasts or resolves. Only what the popup
|
||||
// puts on the wire is passed along, so this also pins down that the wire
|
||||
// payload is sufficient on its own.
|
||||
describe("popup signing sequence to background verification", () => {
|
||||
// End-to-end over the messaging boundary, without a browser: the background
|
||||
// populates the transaction, the object that produces crosses to the popup as
|
||||
// JSON and is signed there, and the artifact goes back to the exact check the
|
||||
// background runs before it broadcasts. Only what each side puts on the wire is
|
||||
// passed along, so this also pins down that the wire payloads are sufficient on
|
||||
// their own.
|
||||
describe("background population to popup signing to verification", () => {
|
||||
// Stand-in for the JSON-RPC provider. populateTransaction only needs the
|
||||
// nonce, the gas estimate, the network and the fee data.
|
||||
const fakeProvider = {
|
||||
@@ -1084,33 +1242,52 @@ describe("popup signing sequence to background verification", () => {
|
||||
// through getSignerForAddress() the way the popup does.
|
||||
const walletData = { type: "privkey" };
|
||||
|
||||
async function popupSignsTx(txParams) {
|
||||
const localSigner = getSignerForAddress(walletData, 0, SIGNER_KEY);
|
||||
const connected = localSigner.connect(fakeProvider);
|
||||
const populated = await connected.populateTransaction(txParams);
|
||||
delete populated.from;
|
||||
return connected.signTransaction(populated);
|
||||
// What the background does before the approval window opens.
|
||||
async function backgroundPrepares(txParams) {
|
||||
const approvedTx = await prepareApprovalTx(
|
||||
fakeProvider,
|
||||
signer.address,
|
||||
txParams,
|
||||
);
|
||||
// Extension messaging is JSON; the popup sees the other side of it.
|
||||
return JSON.parse(JSON.stringify(approvedTx));
|
||||
}
|
||||
|
||||
test("a populated, signed transaction is accepted and broadcastable", async () => {
|
||||
const rawSignedTx = await popupSignsTx(TX_PARAMS);
|
||||
// What the popup does with it: signs it as given, populating nothing.
|
||||
async function popupSigns(approvedTx) {
|
||||
const localSigner = getSignerForAddress(walletData, 0, SIGNER_KEY);
|
||||
return localSigner.signTransaction({ ...approvedTx });
|
||||
}
|
||||
|
||||
test("the populated transaction is what gets signed and what gets checked", async () => {
|
||||
const approvedTx = await backgroundPrepares(TX_PARAMS);
|
||||
const rawSignedTx = await popupSigns(approvedTx);
|
||||
const parsed = verifySignedTx(
|
||||
rawSignedTx,
|
||||
TX_PARAMS,
|
||||
approvedTx,
|
||||
signer.address,
|
||||
SELECTED,
|
||||
);
|
||||
expect(parsed.nonce).toBe(7);
|
||||
expect(parsed.chainId).toBe(1n);
|
||||
expect(parsed.gasLimit).toBe(21000n);
|
||||
expect(parsed.maxFeePerGas).toBe(2000000000n);
|
||||
expect(parsed.to).toBe(RECIPIENT);
|
||||
expect(parsed.value).toBe(BigInt(TX_PARAMS.value));
|
||||
expect(parsed.data).toBe(TX_PARAMS.data);
|
||||
expect(parsed.signature).not.toBeNull();
|
||||
// Every field the screen shows, and the artifact, are the same numbers.
|
||||
expect(BigInt(approvedTx.nonce)).toBe(BigInt(parsed.nonce));
|
||||
expect(BigInt(approvedTx.gasLimit)).toBe(parsed.gasLimit);
|
||||
expect(BigInt(approvedTx.maxFeePerGas)).toBe(parsed.maxFeePerGas);
|
||||
expect(BigInt(approvedTx.maxPriorityFeePerGas)).toBe(
|
||||
parsed.maxPriorityFeePerGas,
|
||||
);
|
||||
});
|
||||
|
||||
test("the wire payload carries no password and no secret", async () => {
|
||||
const rawSignedTx = await popupSignsTx(TX_PARAMS);
|
||||
const approvedTx = await backgroundPrepares(TX_PARAMS);
|
||||
const rawSignedTx = await popupSigns(approvedTx);
|
||||
const payload = {
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id: "test-approval-id",
|
||||
@@ -1128,20 +1305,54 @@ describe("popup signing sequence to background verification", () => {
|
||||
expect(wire).not.toContain(SIGNER_KEY.slice(2).toLowerCase());
|
||||
});
|
||||
|
||||
// The popup is the component whose compromise this check exists to detect,
|
||||
// so it is given the approved transaction and signs something else.
|
||||
test("a popup that signs a different fee than it was given is refused", async () => {
|
||||
const approvedTx = await backgroundPrepares(TX_PARAMS);
|
||||
const rawSignedTx = await popupSigns({
|
||||
...approvedTx,
|
||||
maxFeePerGas: "0x3b9aca000",
|
||||
});
|
||||
expect(() =>
|
||||
verifySignedTx(rawSignedTx, approvedTx, signer.address, SELECTED),
|
||||
).toThrow(/approved maximum fee per gas/);
|
||||
});
|
||||
|
||||
test("a popup that signs a different nonce than it was given is refused", async () => {
|
||||
const approvedTx = await backgroundPrepares(TX_PARAMS);
|
||||
const rawSignedTx = await popupSigns({ ...approvedTx, nonce: "0x8" });
|
||||
expect(() =>
|
||||
verifySignedTx(rawSignedTx, approvedTx, signer.address, SELECTED),
|
||||
).toThrow(/approved nonce/);
|
||||
});
|
||||
|
||||
test("the background rejects a transaction the popup did not approve", async () => {
|
||||
const rawSignedTx = await popupSignsTx({
|
||||
...TX_PARAMS,
|
||||
const approvedTx = await backgroundPrepares(TX_PARAMS);
|
||||
const rawSignedTx = await popupSigns({
|
||||
...approvedTx,
|
||||
to: OTHER_RECIPIENT,
|
||||
});
|
||||
expect(() =>
|
||||
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address, SELECTED),
|
||||
verifySignedTx(rawSignedTx, approvedTx, signer.address, SELECTED),
|
||||
).toThrow(/approved recipient/);
|
||||
});
|
||||
|
||||
test("the background rejects a transaction populated on another network", async () => {
|
||||
const rawSignedTx = await popupSignsTx(TX_PARAMS);
|
||||
const approvedTx = await backgroundPrepares(TX_PARAMS);
|
||||
const rawSignedTx = await popupSigns(approvedTx);
|
||||
expect(() =>
|
||||
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address, SEPOLIA),
|
||||
verifySignedTx(rawSignedTx, approvedTx, signer.address, SEPOLIA),
|
||||
).toThrow(/different network than the one that is selected/);
|
||||
});
|
||||
|
||||
// ethers refuses to sign for an address that is not the key's own, so a
|
||||
// popup working from the approved object cannot quietly sign as whichever
|
||||
// address the user has switched to.
|
||||
test("the approved from stops the popup signing with another key", async () => {
|
||||
const approvedTx = await backgroundPrepares(TX_PARAMS);
|
||||
const otherSigner = getSignerForAddress(walletData, 0, OTHER_KEY);
|
||||
await expect(
|
||||
otherSigner.signTransaction({ ...approvedTx }),
|
||||
).rejects.toThrow(/from address mismatch/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user