A swap can give another spender an allowance that its Amount figure leaves out, and these two screens did not show it. Both now show each Permit2 allowance amount under the figure, with the Moved from the sender amounts. movedFromSenderHtml() is now movedOrAllowedHtml(), as it shows both. An amount too long for one line now wraps on these screens, rather than losing its last digits at the screen's edge. Model: opus-5-5
489 lines
17 KiB
JavaScript
489 lines
17 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");
|
|
});
|
|
});
|
|
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/722: a WRAP_ETH of 1 ETH into the
|
|
// router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH the user
|
|
// settles. The Amount line reads 1.0000 ETH, though up to 2 ETH can leave. The
|
|
// rest is on the swap's own Moved from the sender line.
|
|
describe("a swap with a Moved from the sender line of its own", () => {
|
|
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
|
|
const TWO_ETH = 2n * ONE_ETH;
|
|
const SEND_FAILS = {
|
|
error: "The transaction could not be sent.",
|
|
retryable: false,
|
|
};
|
|
const data = routerIface.encodeFunctionData("execute", [
|
|
"0x0b0c10",
|
|
[
|
|
// WRAP_ETH and UNWRAP_WETH have the same layout.
|
|
coder.encode(["address", "uint256"], [ADDRESS_THIS, ONE_ETH]),
|
|
coder.encode(["address", "uint256"], [ADDRESS_THIS, 0n]),
|
|
coder.encode(
|
|
["bytes", "bytes[]"],
|
|
[
|
|
new Uint8Array([SWAP_EXACT_IN_SINGLE, SETTLE, TAKE_ALL]),
|
|
[
|
|
coder.encode(
|
|
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
|
|
[[ETH_USDC_POOL, true, ONE_ETH, 990000n, "0x"]],
|
|
),
|
|
// The user pays this one, not the router.
|
|
coder.encode(
|
|
["address", "uint256", "bool"],
|
|
[ZERO, 0n, true],
|
|
),
|
|
takeAllUsdc,
|
|
],
|
|
],
|
|
),
|
|
],
|
|
9999999999n,
|
|
]);
|
|
|
|
function expectMovedLine(id) {
|
|
const html = node(id).innerHTML;
|
|
expect(html).toContain("Moved from the sender");
|
|
expect(html).toContain("Up to 1.0000 ETH");
|
|
}
|
|
|
|
test("the wait screen shows that line's amount under the Amount figure", async () => {
|
|
await approveSwap(data, { txHash: TX_HASH }, TWO_ETH);
|
|
expect(node("wait-tx-summary").textContent).toBe("1.0000 ETH");
|
|
expectMovedLine("wait-tx-token-note");
|
|
});
|
|
|
|
test("the error screen shows that line's amount under the Amount figure", async () => {
|
|
await approveSwap(data, SEND_FAILS, TWO_ETH);
|
|
expect(node("error-tx-summary").textContent).toBe("1.0000 ETH");
|
|
expectMovedLine("error-tx-token-note");
|
|
});
|
|
|
|
test("a reopened popup's wait screen still shows it", async () => {
|
|
await approveSwap(data, { txHash: TX_HASH }, TWO_ETH);
|
|
node("wait-tx-token-note").innerHTML = "";
|
|
txStatus.restoreWait();
|
|
expectMovedLine("wait-tx-token-note");
|
|
});
|
|
|
|
test("a reopened popup's error screen still shows it", async () => {
|
|
await approveSwap(data, SEND_FAILS, TWO_ETH);
|
|
node("error-tx-token-note").innerHTML = "";
|
|
txStatus.renderError();
|
|
expectMovedLine("error-tx-token-note");
|
|
});
|
|
});
|
|
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/733: a PERMIT2_PERMIT that gives
|
|
// another spender an unlimited USDC allowance, then a V4 swap of 1 ETH. The
|
|
// Amount line reads 1.0000 ETH and says nothing of the allowance.
|
|
describe("a swap that gives another spender an allowance", () => {
|
|
const OTHER_SPENDER = "0x1111111111111111111111111111111111111111";
|
|
const MAX_UINT160 = (1n << 160n) - 1n;
|
|
const SEND_FAILS = {
|
|
error: "The transaction could not be sent.",
|
|
retryable: false,
|
|
};
|
|
const data = routerIface.encodeFunctionData("execute", [
|
|
"0x0a10",
|
|
[
|
|
coder.encode(
|
|
[
|
|
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
|
|
"bytes",
|
|
],
|
|
[
|
|
[[USDC, MAX_UINT160, 0, 0], OTHER_SPENDER, 9999999999n],
|
|
"0x1234",
|
|
],
|
|
),
|
|
coder.encode(
|
|
["bytes", "bytes[]"],
|
|
[
|
|
new Uint8Array([SWAP_EXACT_IN_SINGLE, SETTLE, TAKE]),
|
|
[
|
|
coder.encode(
|
|
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
|
|
[[ETH_USDC_POOL, true, ONE_ETH, 990000n, "0x"]],
|
|
),
|
|
settleEth,
|
|
takeUsdc,
|
|
],
|
|
],
|
|
),
|
|
],
|
|
9999999999n,
|
|
]);
|
|
|
|
function expectAllowanceLine(id) {
|
|
const html = node(id).innerHTML;
|
|
expect(html).toContain("Permit2 allowance");
|
|
expect(html).toContain("Unlimited");
|
|
}
|
|
|
|
test("the wait screen shows the allowance under the Amount figure", async () => {
|
|
await approveSwap(data, { txHash: TX_HASH }, ONE_ETH);
|
|
expect(node("wait-tx-summary").textContent).toBe("1.0000 ETH");
|
|
expectAllowanceLine("wait-tx-token-note");
|
|
});
|
|
|
|
test("the error screen shows the allowance under the Amount figure", async () => {
|
|
await approveSwap(data, SEND_FAILS, ONE_ETH);
|
|
expect(node("error-tx-summary").textContent).toBe("1.0000 ETH");
|
|
expectAllowanceLine("error-tx-token-note");
|
|
});
|
|
|
|
test("a reopened popup's wait screen still shows it", async () => {
|
|
await approveSwap(data, { txHash: TX_HASH }, ONE_ETH);
|
|
node("wait-tx-token-note").innerHTML = "";
|
|
txStatus.restoreWait();
|
|
expectAllowanceLine("wait-tx-token-note");
|
|
});
|
|
|
|
test("a reopened popup's error screen still shows it", async () => {
|
|
await approveSwap(data, SEND_FAILS, ONE_ETH);
|
|
node("error-tx-token-note").innerHTML = "";
|
|
txStatus.renderError();
|
|
expectAllowanceLine("error-tx-token-note");
|
|
});
|
|
});
|