The swap decoder read no recipient, so a swap paying its output to another address still showed Min. received. It now collects the address that every swap step, SWEEP, WRAP_ETH, UNWRAP_WETH, V4 TAKE and TAKE_ALL pays, across the whole transaction, leaving out payments to the router itself. The minimum reads Min. received only when some step pays the sender, none pays another address, and no step that may pay an address went unread: a recipient that does not decode, or a command or V4 action the decoder does not read. Otherwise it reads Min. paid out, with a Paid to line for each other address and one saying when who is paid is unknown. The approval and transaction detail screens pass the sender in. Model: opus-5-5
194 lines
7.5 KiB
JavaScript
194 lines
7.5 KiB
JavaScript
// What the dApp approval screen says the input token of a swap is when the
|
|
// calldata did not name one.
|
|
//
|
|
// Issue #357, the twin on the input side of
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/353: `tokenInfo(null)` answered
|
|
// `{symbol: "ETH", decimals: 18}`, so a null `inputToken` rendered as
|
|
// `Token In: ETH (native)` and titled the swap `Swap ETH -> X`. An
|
|
// undetermined input was therefore asserted to the user as native ETH — the
|
|
// same class as https://git.eeqj.de/sneak/AutistMask/issues/340 and
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/306, naming the wrong asset
|
|
// rather than merely mis-scaling it.
|
|
//
|
|
// The determination this file pins is the one #353 established, checked here
|
|
// on the input side: null is NOT how native ETH arrives. v4-core declares
|
|
// `type Currency is address` and wraps `address(0)` for native ETH, and a
|
|
// user-defined value type over `address` carries the plain `address` ABI
|
|
// encoding, so a native-ETH currency reaches the decoder as the truthy string
|
|
// "0x0000000000000000000000000000000000000000". WRAP_ETH sets that same
|
|
// explicit zero address. Both halves are asserted below: the zero address
|
|
// stays ETH, and null refuses.
|
|
|
|
const { AbiCoder, Interface, solidityPacked } = require("ethers");
|
|
const uniswap = require("../src/shared/uniswap");
|
|
|
|
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
|
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
const ZERO = "0x0000000000000000000000000000000000000000";
|
|
// Both in the bundled list: USDT at 6 decimals, USDC at 6.
|
|
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
|
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
|
|
|
// The same wording the output side refuses with — one screen, one vocabulary.
|
|
const REFUSAL = "Unknown (not named in the calldata)";
|
|
|
|
const ONE_ETH = 1000000000000000000n;
|
|
|
|
const V4_SWAP_EXACT_IN = 0x07;
|
|
const V4_SETTLE = 0x0b;
|
|
const V4_TAKE = 0x0e;
|
|
|
|
const coder = AbiCoder.defaultAbiCoder();
|
|
const routerIface = new Interface([
|
|
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
|
|
]);
|
|
|
|
function execute(commands, inputs) {
|
|
return routerIface.encodeFunctionData("execute", [
|
|
commands,
|
|
inputs,
|
|
9999999999n,
|
|
]);
|
|
}
|
|
|
|
function v4Input(actions, params) {
|
|
return coder.encode(
|
|
["bytes", "bytes[]"],
|
|
[new Uint8Array(actions), params],
|
|
);
|
|
}
|
|
|
|
// IV4Router.ExactInputParams as the decoder reads it:
|
|
// (Currency currencyIn, PathKey[] path, uint128 amountIn, uint128 minOut).
|
|
function exactInParams(currencyIn, path, amountIn, amountOutMin) {
|
|
return coder.encode(
|
|
[
|
|
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
|
|
],
|
|
[[currencyIn, path, amountIn, amountOutMin]],
|
|
);
|
|
}
|
|
|
|
// BALANCE_CHECK_ERC20 (command 0x0e): (address owner, address token,
|
|
// uint256 minBalance). It names the output token and nothing about the input.
|
|
function balanceCheck(token, minBalance) {
|
|
return coder.encode(
|
|
["address", "address", "uint256"],
|
|
[USER, token, minBalance],
|
|
);
|
|
}
|
|
|
|
function detail(data, label) {
|
|
const decoded = uniswap.decode(data, ROUTER, "0x1", {});
|
|
expect(decoded).not.toBeNull();
|
|
return decoded.details.find((d) => d.label === label);
|
|
}
|
|
|
|
describe("an execute() whose input currency never decoded", () => {
|
|
// A V4_SWAP whose sub-action params do not decode — an action encoding
|
|
// this decoder does not know — leaves every V4 token null. The
|
|
// BALANCE_CHECK still names the output, so the screen has a minimum
|
|
// figure and a Token Out, and previously claimed the user was paying ETH
|
|
// for them.
|
|
const data = () =>
|
|
execute(solidityPacked(["uint8", "uint8"], [0x10, 0x0e]), [
|
|
coder.encode(["bytes", "bytes[]"], ["0x07", ["0x"]]),
|
|
balanceCheck(USDC, 2000000n),
|
|
]);
|
|
|
|
test("says the input token is unknown instead of naming ETH", () => {
|
|
// The swap action that would name it could not be read, so the line
|
|
// says that rather than REFUSAL's "not named in the calldata".
|
|
expect(detail(data(), "Token In").value).toBe(
|
|
"Unknown (could not be decoded)",
|
|
);
|
|
});
|
|
|
|
test("keeps a Token In line, so the screen never omits what is paid", () => {
|
|
const tokenIn = detail(data(), "Token In");
|
|
expect(tokenIn).toBeDefined();
|
|
// A refusal is not a token: nothing to link to an explorer.
|
|
expect(tokenIn.address).toBeUndefined();
|
|
expect(tokenIn.isToken).toBeUndefined();
|
|
});
|
|
|
|
test("does not name ETH in the swap title either", () => {
|
|
expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe(
|
|
"Uniswap Swap",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("an execute() that names only an output token", () => {
|
|
// A lone BALANCE_CHECK_ERC20: nothing in it says what is being paid.
|
|
const data = () => execute("0x0e", [balanceCheck(USDT, 2000000n)]);
|
|
|
|
test("refuses the input rather than defaulting it to ETH", () => {
|
|
expect(detail(data(), "Token In").value).toBe(REFUSAL);
|
|
expect(uniswap.decode(data(), ROUTER, "0x1", {}).name).toBe(
|
|
"Uniswap Swap",
|
|
);
|
|
});
|
|
|
|
test("still states the output it did establish", () => {
|
|
expect(detail(data(), "Token Out").value).toContain("USDT");
|
|
// No step pays anyone, so the minimum is not said to be received.
|
|
expect(detail(data(), "Min. paid out").value).toBe("2.0000 USDT");
|
|
});
|
|
});
|
|
|
|
describe("native ETH in, which V4 spells as the zero address", () => {
|
|
test("a Currency of address(0) decodes to a truthy address string", () => {
|
|
// The fact the whole determination rests on: an absent input currency
|
|
// and a native-ETH one are distinguishable here, because the ABI
|
|
// decoder never yields null for an address word.
|
|
const [currency] = coder.decode(
|
|
["address", "uint256", "bool"],
|
|
coder.encode(["address", "uint256", "bool"], [ZERO, ONE_ETH, true]),
|
|
);
|
|
expect(currency).toBe(ZERO);
|
|
expect(Boolean(currency)).toBe(true);
|
|
});
|
|
|
|
test("a V4 SETTLE of the zero address is still ETH at 18 decimals", () => {
|
|
const data = execute("0x10", [
|
|
v4Input(
|
|
[V4_SETTLE, V4_SWAP_EXACT_IN, V4_TAKE],
|
|
[
|
|
coder.encode(
|
|
["address", "uint256", "bool"],
|
|
[ZERO, ONE_ETH, true],
|
|
),
|
|
exactInParams(
|
|
ZERO,
|
|
[[USDT, 500, 10, ZERO, "0x"]],
|
|
ONE_ETH,
|
|
2000000n,
|
|
),
|
|
coder.encode(
|
|
["address", "address", "uint256"],
|
|
[USDT, USER, 0n],
|
|
),
|
|
],
|
|
),
|
|
]);
|
|
|
|
expect(detail(data, "Token In").value).toBe("ETH (native)");
|
|
expect(detail(data, "Amount").value).toBe("1.0000 ETH");
|
|
expect(uniswap.decode(data, ROUTER, "0x1", {}).name).toBe(
|
|
"Swap ETH → USDT",
|
|
);
|
|
});
|
|
|
|
test("WRAP_ETH is still ETH at 18 decimals", () => {
|
|
// WRAP_ETH names no currency of its own; the decoder supplies the
|
|
// explicit zero address for it, which is why it survives this change.
|
|
const data = execute("0x0b", [
|
|
coder.encode(["address", "uint256"], [ROUTER, ONE_ETH]),
|
|
]);
|
|
|
|
expect(detail(data, "Token In").value).toBe("ETH (native)");
|
|
expect(detail(data, "Amount").value).toBe("1.0000 ETH");
|
|
});
|
|
});
|