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, unless the swap is paid in native ETH. Model: opus-5-5
343 lines
12 KiB
JavaScript
343 lines
12 KiB
JavaScript
// 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. A swap
|
|
// paid in native ETH still shows the transaction's ETH value there: that value
|
|
// is the ETH the swap pays.
|
|
//
|
|
// 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]);
|
|
|
|
// The same swaps paid in native ETH, which V4 names as the zero address.
|
|
const ETH_USDC_POOL = [ZERO, USDC, 500, 10, ZERO];
|
|
const ONE_ETH = 1000000000000000000n;
|
|
const ethExactOutSingle = coder.encode(
|
|
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
|
|
[[ETH_USDC_POOL, true, 990000n, ONE_ETH, "0x"]],
|
|
);
|
|
const newerEthExactInSingle = coder.encode(
|
|
[`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`],
|
|
[[ETH_USDC_POOL, true, ONE_ETH, 990000n, 0n, "0x"]],
|
|
);
|
|
// The router pays it from the ETH the transaction sends.
|
|
const settleEth = coder.encode(
|
|
["address", "uint256", "bool"],
|
|
[ZERO, 0n, false],
|
|
);
|
|
|
|
// 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 `value`
|
|
// wei (no ETH unless given), and confirm it. The background answers the signed
|
|
// transaction with `response`.
|
|
async function approveSwap(data, response, value = 0n) {
|
|
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: "0x" + value.toString(16),
|
|
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");
|
|
});
|
|
});
|
|
|
|
// A swap paid in native ETH has a Token In of "ETH (native)" and no Amount
|
|
// line, and sends 1 ETH.
|
|
describe.each([
|
|
[
|
|
"an exact-out swap action paid in ETH, in the deployed shape",
|
|
[
|
|
[SWAP_EXACT_OUT_SINGLE, ethExactOutSingle],
|
|
[SETTLE, settleEth],
|
|
[TAKE, takeUsdc],
|
|
],
|
|
],
|
|
[
|
|
"a swap action in the newer shape paid in ETH, with SETTLE naming ETH",
|
|
[
|
|
[SWAP_EXACT_IN_SINGLE, newerEthExactInSingle],
|
|
[SETTLE, settleEth],
|
|
[TAKE, takeUsdc],
|
|
],
|
|
],
|
|
])("%s", (_name, actions) => {
|
|
test("the wait screen shows the ETH the transaction sends", async () => {
|
|
await approveSwap(v4SwapData(actions), { txHash: TX_HASH }, ONE_ETH);
|
|
expect(node("wait-tx-summary").textContent).toBe("1.0000 ETH");
|
|
});
|
|
|
|
test("the error screen shows the ETH the transaction sends", async () => {
|
|
await approveSwap(
|
|
v4SwapData(actions),
|
|
{
|
|
error: "The transaction could not be sent.",
|
|
retryable: false,
|
|
},
|
|
ONE_ETH,
|
|
);
|
|
expect(node("error-tx-summary").textContent).toBe("1.0000 ETH");
|
|
});
|
|
});
|