fix: a V4 swap action not in the deployed shape is shown as not decoded (closes #358)
The four V4 swap actions are read only as the structs deployed on mainnet and Sepolia. ethers read the newer v4-periphery structs, which add minHopPriceX36, as the deployed ones, and the newer multi-hop exact-in struct showed the offset of its hop-price array as the swap amount. A decode now counts only when encoding it again gives back the same bytes. A swap action that fails that marks its step "V4 Swap (could not be decoded)", and a side its SETTLE or TAKE did not name reads "Unknown (could not be decoded)". The wait and error screens no longer show the ETH value as the amount of a swap whose amount was not read. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
// The amount the wait and error screens show for a swap whose amount the
|
||||
// decoder did not read (https://git.eeqj.de/sneak/AutistMask/issues/358).
|
||||
//
|
||||
// A V4 swap action not in the deployed shape is not read, and a V4 exact-out
|
||||
// action is read for its currencies only, so the approval screen has no
|
||||
// Amount line for either. The wait and error screens then showed the
|
||||
// transaction's ETH value as the amount, in the swap's input token when the
|
||||
// calldata named one ("0.0000 WETH" for a swap of 1 WETH) and in ETH when it
|
||||
// did not ("0.0000 ETH"). They now say the amount could not be decoded.
|
||||
//
|
||||
// Driven through the real approval screen, password and Confirm included,
|
||||
// against a minimal DOM stub in the shape tests/approvalNonce.test.js uses.
|
||||
// The vault is mocked so that no password has to be hashed.
|
||||
|
||||
jest.mock("../src/shared/vault", () => ({
|
||||
decryptWithPassword: jest.fn(),
|
||||
}));
|
||||
|
||||
// The wait screen polls for a receipt, which is there once mockReceipt is set.
|
||||
let mockReceipt = null;
|
||||
jest.mock("../src/shared/balances", () => ({
|
||||
getProvider: () => ({ getTransactionReceipt: async () => mockReceipt }),
|
||||
refreshBalances: () => {},
|
||||
}));
|
||||
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
|
||||
const { AbiCoder, Interface } = require("ethers");
|
||||
const { state } = require("../src/shared/state");
|
||||
const { decryptWithPassword } = require("../src/shared/vault");
|
||||
const approval = require("../src/popup/views/approval");
|
||||
const txStatus = require("../src/popup/views/txStatus");
|
||||
|
||||
// A well-known test phrase, and its first address.
|
||||
const PHRASE = "test test test test test test test test test test test junk";
|
||||
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
|
||||
const TX_HASH = "0x" + "4".repeat(64);
|
||||
|
||||
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
||||
const ZERO = "0x0000000000000000000000000000000000000000";
|
||||
// Both in the bundled list: WETH at 18 decimals, USDC at 6.
|
||||
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
|
||||
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||
const ONE_WETH = 1000000000000000000n;
|
||||
|
||||
const coder = AbiCoder.defaultAbiCoder();
|
||||
const routerIface = new Interface([
|
||||
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
|
||||
]);
|
||||
|
||||
// V4 action ids and their parameters, each swapping WETH for USDC.
|
||||
const POOL_KEY = "tuple(address,address,uint24,int24,address)";
|
||||
const WETH_USDC_POOL = [WETH, USDC, 100, 1, ZERO];
|
||||
const SWAP_EXACT_IN_SINGLE = 0x06;
|
||||
const SWAP_EXACT_OUT_SINGLE = 0x08;
|
||||
const SETTLE = 0x0b;
|
||||
const SETTLE_ALL = 0x0c;
|
||||
const TAKE = 0x0e;
|
||||
const TAKE_ALL = 0x0f;
|
||||
|
||||
// The struct V4Router has deployed on mainnet and Sepolia.
|
||||
const exactInSingle = coder.encode(
|
||||
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
|
||||
[[WETH_USDC_POOL, true, ONE_WETH, 990000n, "0x"]],
|
||||
);
|
||||
// The one v4-periphery `main` has moved to, with a `minHopPriceX36` field.
|
||||
const newerExactInSingle = coder.encode(
|
||||
[`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`],
|
||||
[[WETH_USDC_POOL, true, ONE_WETH, 990000n, 0n, "0x"]],
|
||||
);
|
||||
// Deployed shape: buys 990000 base units of USDC for at most 1 WETH.
|
||||
const exactOutSingle = coder.encode(
|
||||
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
|
||||
[[WETH_USDC_POOL, true, 990000n, ONE_WETH, "0x"]],
|
||||
);
|
||||
const settleWeth = coder.encode(
|
||||
["address", "uint256", "bool"],
|
||||
[WETH, 0n, true],
|
||||
);
|
||||
const takeUsdc = coder.encode(
|
||||
["address", "address", "uint256"],
|
||||
[USDC, FROM, 0n],
|
||||
);
|
||||
const settleAllWeth = coder.encode(["address", "uint256"], [WETH, ONE_WETH]);
|
||||
const takeAllUsdc = coder.encode(["address", "uint256"], [USDC, 990000n]);
|
||||
|
||||
// execute() with one V4_SWAP step of these actions, given as [id, params].
|
||||
function v4SwapData(actions) {
|
||||
const input = coder.encode(
|
||||
["bytes", "bytes[]"],
|
||||
[new Uint8Array(actions.map((a) => a[0])), actions.map((a) => a[1])],
|
||||
);
|
||||
return routerIface.encodeFunctionData("execute", [
|
||||
"0x10",
|
||||
[input],
|
||||
9999999999n,
|
||||
]);
|
||||
}
|
||||
|
||||
function makeElement(id) {
|
||||
const classes = new Set();
|
||||
const el = {
|
||||
id,
|
||||
textContent: "",
|
||||
value: "",
|
||||
innerHTML: "",
|
||||
disabled: false,
|
||||
style: {},
|
||||
dataset: {},
|
||||
listeners: {},
|
||||
classList: {
|
||||
add: (...names) => names.forEach((n) => classes.add(n)),
|
||||
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
||||
contains: (n) => classes.has(n),
|
||||
toggle: (n, force) => {
|
||||
const on = force === undefined ? !classes.has(n) : force;
|
||||
if (on) classes.add(n);
|
||||
else classes.delete(n);
|
||||
return on;
|
||||
},
|
||||
},
|
||||
addEventListener: (name, fn) => {
|
||||
el.listeners[name] = el.listeners[name] || [];
|
||||
el.listeners[name].push(fn);
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
appendChild: () => {},
|
||||
};
|
||||
// Views reach for .parentElement to hide whole sections.
|
||||
Object.defineProperty(el, "parentElement", {
|
||||
get: () => node(id + "-parent"),
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function makeDocument() {
|
||||
const els = new Map();
|
||||
return {
|
||||
getElementById(id) {
|
||||
// The debug banner is created on demand by helpers.js; absent
|
||||
// is the state a non-debug, non-testnet popup is in.
|
||||
if (id === "debug-banner") return null;
|
||||
if (!els.has(id)) els.set(id, makeElement(id));
|
||||
return els.get(id);
|
||||
},
|
||||
createElement: () => makeElement("created"),
|
||||
body: { prepend: () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
function node(id) {
|
||||
return globalThis.document.getElementById(id);
|
||||
}
|
||||
|
||||
function click(id) {
|
||||
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
|
||||
}
|
||||
|
||||
// Open the approval screen for this calldata, sent to the router with no ETH,
|
||||
// and confirm it. The background answers the signed transaction with
|
||||
// `response`.
|
||||
async function approveSwap(data, response) {
|
||||
globalThis.document = makeDocument();
|
||||
globalThis.window = { location: { search: "" }, close: () => {} };
|
||||
globalThis.chrome.runtime = {
|
||||
connect: () => ({ postMessage: () => {} }),
|
||||
sendMessage: (msg, reply) => {
|
||||
if (!reply) return;
|
||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(response);
|
||||
reply({
|
||||
type: "tx",
|
||||
origin: "https://dapp.example",
|
||||
isPhishingDomain: false,
|
||||
approvedFrom: FROM,
|
||||
approvedTx: {
|
||||
type: 2,
|
||||
from: FROM,
|
||||
chainId: "0x1",
|
||||
nonce: "0x7",
|
||||
gasLimit: "0x30d40",
|
||||
maxPriorityFeePerGas: "0x3b9aca00",
|
||||
maxFeePerGas: "0x77359400",
|
||||
to: ROUTER,
|
||||
value: "0x0",
|
||||
data,
|
||||
accessList: [],
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
state.activeAddress = FROM;
|
||||
state.trackedTokens = [];
|
||||
state.wallets = [
|
||||
{
|
||||
type: "hd",
|
||||
name: "Wallet 1",
|
||||
xpub: "xpub-wallet-1",
|
||||
encryptedSecret: "encrypted-secret-1",
|
||||
nextIndex: 1,
|
||||
addresses: [
|
||||
{ address: FROM, index: 0, balance: "0", tokenBalances: [] },
|
||||
],
|
||||
},
|
||||
];
|
||||
txStatus.init({ doRefreshAndRender: () => {} });
|
||||
approval.init({});
|
||||
await approval.show(1);
|
||||
decryptWithPassword.mockResolvedValue(PHRASE);
|
||||
node("approve-tx-password").value = "any password";
|
||||
await click("btn-approve-tx");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
txStatus.endWait();
|
||||
mockReceipt = null;
|
||||
});
|
||||
|
||||
describe.each([
|
||||
[
|
||||
"a swap action in the deployed shape",
|
||||
[
|
||||
[SWAP_EXACT_IN_SINGLE, exactInSingle],
|
||||
[SETTLE, settleWeth],
|
||||
[TAKE, takeUsdc],
|
||||
],
|
||||
"1.0000 WETH",
|
||||
],
|
||||
[
|
||||
"a swap action in the newer shape, with SETTLE naming WETH",
|
||||
[
|
||||
[SWAP_EXACT_IN_SINGLE, newerExactInSingle],
|
||||
[SETTLE, settleWeth],
|
||||
[TAKE, takeUsdc],
|
||||
],
|
||||
"Unknown (could not be decoded)",
|
||||
],
|
||||
[
|
||||
"a swap action in the newer shape, with SETTLE_ALL and TAKE_ALL",
|
||||
[
|
||||
[SWAP_EXACT_IN_SINGLE, newerExactInSingle],
|
||||
[SETTLE_ALL, settleAllWeth],
|
||||
[TAKE_ALL, takeAllUsdc],
|
||||
],
|
||||
"Unknown (could not be decoded)",
|
||||
],
|
||||
[
|
||||
"an exact-out swap action, which is read for its currencies only",
|
||||
[
|
||||
[SWAP_EXACT_OUT_SINGLE, exactOutSingle],
|
||||
[SETTLE, settleWeth],
|
||||
[TAKE, takeUsdc],
|
||||
],
|
||||
"Unknown (could not be decoded)",
|
||||
],
|
||||
])("%s", (_name, actions, expected) => {
|
||||
test("the wait screen's amount", async () => {
|
||||
await approveSwap(v4SwapData(actions), { txHash: TX_HASH });
|
||||
expect(node("wait-tx-summary").textContent).toBe(expected);
|
||||
});
|
||||
|
||||
test("the error screen's amount", async () => {
|
||||
await approveSwap(v4SwapData(actions), {
|
||||
error: "The transaction could not be sent.",
|
||||
retryable: false,
|
||||
});
|
||||
expect(node("error-tx-summary").textContent).toBe(expected);
|
||||
});
|
||||
|
||||
// The success screen shows the approval screen's decoded lines in place
|
||||
// of the amount, so the ETH value is not on it either.
|
||||
test("the success screen has no amount line of its own", async () => {
|
||||
await approveSwap(v4SwapData(actions), { txHash: TX_HASH });
|
||||
mockReceipt = { blockNumber: 1 };
|
||||
// A later popup resumes the wait, and the receipt is there.
|
||||
txStatus.restoreWait();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
const summary = node("success-tx-summary").parentElement;
|
||||
expect(summary.classList.contains("hidden")).toBe(true);
|
||||
expect(node("success-tx-decoded").innerHTML).not.toContain("0.0000");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user