Files
AutistMask/tests/uniswapRecipient.test.js
T
sneak 70af00417c
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: a step the router lets fail is marked and sets no minimum (closes #602)
Each Universal Router release now carries the command types its dispatch()
can return a failure for instead of reverting, read from its source; only a
step of one of those with the allow-revert flag (0x80) set can fail while the
transaction goes on. Such a step is listed with "(allowed to fail)". A minimum
such a step states guarantees nothing, so it is read as zero: a
BALANCE_CHECK_ERC20 then keeps an earlier step's minimum, and 0x10 on
UniversalRouterV1 and UniversalRouterV1_2_V2Support, an NFT call there still
read as a V4 swap, states no minimum. Every swap and every payment of ETH or an
ERC-20 token succeeds or reverts the whole transaction, so the flag on them has
no effect.

Model: opus-5-5
2026-10-19 13:05:00 +00:00

1885 lines
64 KiB
JavaScript

// The address a swap's output is paid to
// (https://git.eeqj.de/sneak/AutistMask/issues/594 and
// https://git.eeqj.de/sneak/AutistMask/issues/599), and the allow-revert flag
// on the steps that pay it
// (https://git.eeqj.de/sneak/AutistMask/issues/602).
//
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
// V4 TAKE and TAKE_PORTION names the address it pays, and TAKE_ALL pays the
// sender. The decoder read none of them: a swap whose output went to another
// address still read "Min. received", as if the user received it. The router
// reads address(1) as the sender and address(2) as the router itself, which
// holds what it is paid until a later step pays it out.
//
// The screens are driven against a minimal DOM stub in the shape
// tests/uniswapRouterAddress.test.js uses.
const { AbiCoder, Interface, solidityPacked } = require("ethers");
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: async (url) => ({
ok: true,
json: async () => mockExplorerAnswer(url),
}),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const uniswap = require("../src/shared/uniswap");
const { state } = require("../src/shared/state");
const approval = require("../src/popup/views/approval");
const transactionDetail = require("../src/popup/views/transactionDetail");
// UniversalRouterV2 on Ethereum Mainnet.
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
// UniversalRouterV2_1_2 on Ethereum Mainnet, whose mask reads 0x07 and 0x40.
const ROUTER_V2_1_2 = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85";
// UniversalRouterV1 on Ethereum Mainnet.
const ROUTER_V1 = "0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B";
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const OTHER = "0x7777777777777777777777777777777777777777";
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
const ZERO = "0x0000000000000000000000000000000000000000";
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
// A token the wallet does not know.
const UNKNOWN_TOKEN = "0x5555555555555555555555555555555555555555";
const ONE_WETH = 1000000000000000000n;
const USDT_2000 = 2000000000n;
const USDC_1990 = 1990000000n;
// The router's whole-balance amount.
const CONTRACT_BALANCE = 2n ** 255n;
// The Paid to line's value when no address can be named.
const UNDECODED = "Unknown (could not be decoded)";
const UNNAMED = "Unknown (not named in the calldata)";
const STAYS_IN_ROUTER =
"The router, where it stays (no later step pays it out)";
const PAID_TO_BRIDGE =
"The bridge, which pays the deposit's recipient on another network";
// The Paid to line for a PERMIT2_TRANSFER_FROM of `amount` into the router.
const movedIntoRouter = (amount) =>
"The router, " +
amount +
" from the sender (later steps may not spend all of it)";
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// execute() of `steps`, each [command, input].
function execute(...steps) {
const commands = steps.map(([command]) => command);
return routerIface.encodeFunctionData("execute", [
solidityPacked(
commands.map(() => "uint8"),
commands,
),
steps.map(([, input]) => input),
9999999999n,
]);
}
// V2_SWAP_EXACT_IN (0x08) and V2_SWAP_EXACT_OUT (0x09) share one layout.
function v2Swap(command, recipient, amount, limit, path, payerIsUser = true) {
return [
command,
coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[recipient, amount, limit, path, payerIsUser],
),
];
}
function v3SwapExactIn(
recipient,
amountIn,
amountOutMin,
tokenIn,
tokenOut,
payerIsUser = true,
) {
return [
0x00,
coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
recipient,
amountIn,
amountOutMin,
solidityPacked(
["address", "uint24", "address"],
[tokenIn, 3000, tokenOut],
),
payerIsUser,
],
),
];
}
function sweep(token, recipient) {
return [
0x04,
coder.encode(["address", "address", "uint256"], [token, recipient, 0n]),
];
}
// TRANSFER (0x05), PAY_PORTION (0x06) and PAY_PORTION_FULL_PRECISION (0x07)
// share one layout. `amount` is a quantity, bips, or 1e18ths respectively.
function transfer(command, token, recipient, amount) {
return [
command,
coder.encode(
["address", "address", "uint256"],
[token, recipient, amount],
),
];
}
// PERMIT2_PERMIT (0x0a) of `amount` of `token`, for the router to spend.
function permit2Permit(token, amount) {
return [
0x0a,
coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
"bytes",
],
[[[token, amount, 0, 0], ROUTER, 0n], "0x"],
),
];
}
function permit2TransferFrom(token, recipient, amount) {
return [
0x02,
coder.encode(
["address", "address", "uint160"],
[token, recipient, amount],
),
];
}
// ACROSS_V4_DEPOSIT_V3 (0x40) of `inputAmount` of `inputToken`, bridged to
// chain id 8453.
function acrossDeposit(
depositor,
recipient,
inputToken,
inputAmount,
useNative = false,
) {
return [
0x40,
coder.encode(
[
"tuple(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes,bool)",
],
[
[
depositor,
recipient,
inputToken,
inputToken,
inputAmount,
1n,
8453n,
ZERO,
0,
0,
0,
"0x",
useNative,
],
],
),
];
}
function wrapEth(recipient, amount) {
return [0x0b, coder.encode(["address", "uint256"], [recipient, amount])];
}
function unwrapWeth(recipient) {
return [0x0c, coder.encode(["address", "uint256"], [recipient, 0n])];
}
function balanceCheck(token, minBalance) {
return [
0x0e,
coder.encode(
["address", "address", "uint256"],
[USER, token, minBalance],
),
];
}
// `step`, a [command or action, input] pair, with the top byte of the input's
// word `index` set. The decoder cannot read that word as an address.
function withDirtyWord([id, input], index) {
const at = 2 + index * 64;
return [id, input.slice(0, at) + "ff" + input.slice(at + 2)];
}
// The V4 actions a step below is built from.
const SWAP_USDT_FOR_USDC = [
0x06, // SWAP_EXACT_IN_SINGLE
coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[[[USDT, USDC, 100, 1, ZERO], true, USDT_2000, USDC_1990, "0x"]],
),
];
const SWAP_USDT_FOR_ETH = [
0x06, // SWAP_EXACT_IN_SINGLE
coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[[[ZERO, USDT, 500, 10, ZERO], false, USDT_2000, ONE_WETH, "0x"]],
),
];
// SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read.
const UNREADABLE_SWAP = [0x06, "0x"];
const SETTLE_USDT = [
0x0b,
coder.encode(["address", "uint256", "bool"], [USDT, 0n, true]),
];
function take(currency, recipient) {
return [
0x0e,
coder.encode(
["address", "address", "uint256"],
[currency, recipient, 0n],
),
];
}
function takeAll(currency) {
return [0x0f, coder.encode(["address", "uint256"], [currency, 0n])];
}
function takePortion(currency, recipient, bips) {
return [
0x10,
coder.encode(
["address", "address", "uint256"],
[currency, recipient, bips],
),
];
}
// A V4_SWAP step of `actions`, each [action, params].
function v4Swap(...actions) {
return [
0x10,
coder.encode(
["bytes", "bytes[]"],
[
solidityPacked(
actions.map(() => "uint8"),
actions.map(([action]) => action),
),
actions.map(([, params]) => params),
],
),
];
}
function decode(data, sender = USER, router = ROUTER) {
return uniswap.decode(data, router, "0x1", {}, sender);
}
function labels(result) {
return result.details.map((d) => d.label);
}
function detail(result, label) {
return result.details.find((d) => d.label === label);
}
function paidTo(result) {
return result.details
.filter((d) => d.label === "Paid to")
.map((d) => d.value);
}
// A swap paid to the user, the lines it has always had.
const PAID_TO_USER = [
"Protocol",
"Token In",
"Amount",
"Token Out",
"Min. received",
"Steps",
"Deadline",
];
const PAID_ELSEWHERE = [
"Protocol",
"Token In",
"Amount",
"Token Out",
"Paid to",
"Min. paid out",
"Steps",
"Deadline",
];
// A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`.
const swapToWeth = (recipient) =>
v3SwapExactIn(recipient, USDT_2000, ONE_WETH, USDT, WETH);
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("each swap command's recipient", () => {
test.each([
{
kind: "V2_SWAP_EXACT_IN",
data: (r) =>
execute(v2Swap(0x08, r, USDT_2000, ONE_WETH, [USDT, WETH])),
min: "1.0000 WETH",
},
{
kind: "V2_SWAP_EXACT_OUT",
data: (r) =>
execute(v2Swap(0x09, r, ONE_WETH, USDT_2000, [USDT, WETH])),
min: "1.0000 WETH",
},
{
kind: "V3_SWAP_EXACT_IN",
data: (r) => execute(swapToWeth(r)),
min: "1.0000 WETH",
},
{
kind: "V4_SWAP with TAKE",
data: (r) =>
execute(v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, r))),
min: "1990.0000 USDC",
},
])(
"$kind: paid to the user it reads as before; paid to another address it names that address",
({ data, min }) => {
const viaPlaceholder = decode(data(MSG_SENDER));
expect(labels(viaPlaceholder)).toEqual(PAID_TO_USER);
expect(detail(viaPlaceholder, "Min. received").value).toBe(min);
// The sender's own address, in any case, reads the same.
expect(decode(data(USER), USER.toLowerCase()).details).toEqual(
viaPlaceholder.details,
);
const toOther = decode(data(OTHER));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe(min);
},
);
});
describe("an output the router holds", () => {
const toRouter = v3SwapExactIn(
ADDRESS_THIS,
USDT_2000,
USDC_1990,
USDT,
USDC,
);
test("SWEEP pays it to the SWEEP's recipient", () => {
const direct = decode(
execute(
v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC),
),
);
const toUser = decode(execute(toRouter, sweep(USDC, MSG_SENDER)));
expect(labels(toUser)).toEqual(PAID_TO_USER);
const withoutSteps = (r) =>
r.details.filter((d) => d.label !== "Steps");
expect(withoutSteps(toUser)).toEqual(withoutSteps(direct));
const toOther = decode(execute(toRouter, sweep(USDC, OTHER)));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe("1990.0000 USDC");
});
test("with nothing paying it out, it stays in the router", () => {
const result = decode(execute(toRouter));
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
});
test("the router's own address as a recipient is read as address(2) is", () => {
const toRouterAddress = v3SwapExactIn(
ROUTER,
USDT_2000,
USDC_1990,
USDT,
USDC,
);
expect(paidTo(decode(execute(toRouterAddress)))).toEqual([
STAYS_IN_ROUTER,
]);
expect(
labels(decode(execute(toRouterAddress, sweep(USDC, MSG_SENDER)))),
).toEqual(PAID_TO_USER);
});
test("UNWRAP_WETH pays its WETH, as ETH, to the UNWRAP_WETH's recipient", () => {
const toWeth = swapToWeth(ADDRESS_THIS);
const toUser = decode(execute(toWeth, unwrapWeth(MSG_SENDER)));
expect(labels(toUser)).toEqual(PAID_TO_USER);
expect(detail(toUser, "Token Out").value).toBe("ETH");
expect(detail(toUser, "Min. received").value).toBe("1.0000 ETH");
const toOther = decode(execute(toWeth, unwrapWeth(OTHER)));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe("1.0000 ETH");
});
test("WRAP_ETH pays its WETH to the WRAP_ETH's recipient", () => {
const toUser = decode(
execute(wrapEth(ADDRESS_THIS, ONE_WETH), unwrapWeth(MSG_SENDER)),
);
expect(detail(toUser, "Token Out").value).toBe("ETH");
expect(paidTo(toUser)).toEqual([]);
const toOther = decode(
execute(wrapEth(OTHER, ONE_WETH), unwrapWeth(MSG_SENDER)),
);
expect(paidTo(toOther)).toEqual([OTHER]);
});
test("a V4 TAKE to the router, then SWEEP, pays the SWEEP's recipient", () => {
const step = v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, ADDRESS_THIS),
);
const toUser = decode(execute(step, sweep(USDC, MSG_SENDER)));
expect(labels(toUser)).toEqual(PAID_TO_USER);
const toOther = decode(execute(step, sweep(USDC, OTHER)));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
});
});
// Only a later SWEEP of the output token, or UNWRAP_WETH of WETH, pays the
// router's output out. A payment to the user of another token, of nothing, or
// before the swap leaves the output in the router.
describe("an output left in the router is not received", () => {
const toRouter = swapToWeth(ADDRESS_THIS);
test.each([
{
kind: "a SWEEP of WETH to the user, then the swap",
data: execute(sweep(WETH, MSG_SENDER), toRouter),
min: "1.0000 WETH",
},
{
kind: "an UNWRAP_WETH to the user, then the swap",
data: execute(unwrapWeth(MSG_SENDER), toRouter),
min: "1.0000 ETH",
},
{
kind: "the swap, then a WRAP_ETH of zero to the user",
data: execute(toRouter, wrapEth(MSG_SENDER, 0n)),
min: "1.0000 WETH",
},
{
kind: "the swap, then a SWEEP of a token it does not buy to the user",
data: execute(toRouter, sweep(USDC, MSG_SENDER)),
min: "1.0000 WETH",
},
])("$kind", ({ data, min }) => {
const result = decode(data);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Min. paid out").value).toBe(min);
});
test("one V4 step: a TAKE of what it buys to the router, then a TAKE_ALL of it, which takes nothing", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, ADDRESS_THIS),
takeAll(USDC),
),
),
);
expect(detail(result, "Steps").value).toBe("V4 Swap");
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/599: TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION and V4 TAKE_PORTION pay a token to an address,
// and PERMIT2_TRANSFER_FROM moves the user's own tokens to one.
describe("each transfer command's recipient", () => {
// Each pays out all of a swap's output. Paid to another address, this is
// the first shape the issue reports.
test.each([
{
kind: "TRANSFER of CONTRACT_BALANCE",
data: (r) =>
execute(
swapToWeth(ADDRESS_THIS),
transfer(0x05, WETH, r, CONTRACT_BALANCE),
),
min: "1.0000 WETH",
},
{
kind: "PAY_PORTION of 10,000 bips",
data: (r) =>
execute(
swapToWeth(ADDRESS_THIS),
transfer(0x06, WETH, r, 10000n),
),
min: "1.0000 WETH",
},
{
kind: "PAY_PORTION_FULL_PRECISION of 1e18",
data: (r) =>
execute(
swapToWeth(ADDRESS_THIS),
transfer(0x07, WETH, r, 10n ** 18n),
),
router: ROUTER_V2_1_2,
min: "1.0000 WETH",
},
{
kind: "V4 TAKE_PORTION of 10,000 bips",
data: (r) =>
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
takePortion(USDC, r, 10000n),
),
),
min: "1990.0000 USDC",
},
])(
"$kind: paid to the user it is received, to the router it stays there, to another address that address is named",
({ data, router = ROUTER, min }) => {
const toUser = decode(data(MSG_SENDER), USER, router);
expect(labels(toUser)).toEqual(PAID_TO_USER);
expect(detail(toUser, "Min. received").value).toBe(min);
expect(decode(data(USER), USER, router).details).toEqual(
toUser.details,
);
const toRouter = decode(data(ADDRESS_THIS), USER, router);
expect(labels(toRouter)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toRouter)).toEqual([STAYS_IN_ROUTER]);
expect(detail(toRouter, "Min. paid out").value).toBe(min);
const toOther = decode(data(OTHER), USER, router);
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe(min);
},
);
// The user's USDT, moved to `r`, then a swap of it paid to the user. A
// move to another address is the issue's second shape.
const movedThenSwapped = (r) =>
execute(
permit2TransferFrom(USDT, r, USDT_2000),
v3SwapExactIn(MSG_SENDER, USDT_2000, ONE_WETH, USDT, WETH, false),
);
test("PERMIT2_TRANSFER_FROM: to the user it pays no one else, to another address that address is named", () => {
for (const r of [MSG_SENDER, USER]) {
const result = decode(movedThenSwapped(r));
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe("1.0000 WETH");
}
const toOther = decode(movedThenSwapped(OTHER));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe("1.0000 WETH");
const alone = decode(
execute(permit2TransferFrom(USDT, OTHER, USDT_2000)),
);
expect(detail(alone, "Steps").value).toBe("Permit2 Transfer");
expect(paidTo(alone)).toEqual([OTHER]);
});
// Later steps may leave part of what a move into the router moves there,
// where anyone can sweep it in a later transaction.
test.each([
{ kind: "address(2)", r: ADDRESS_THIS },
{ kind: "the router's own address", r: ROUTER },
])(
"PERMIT2_TRANSFER_FROM into the router as $kind names what it moves, and nothing is received",
({ r }) => {
const result = decode(movedThenSwapped(r));
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
"Token Out",
"Paid to",
"Token moved into the router",
"Min. paid out",
"Steps",
"Deadline",
]);
expect(paidTo(result)).toEqual([movedIntoRouter("2000.0000 USDT")]);
expect(detail(result, "Token moved into the router")).toEqual({
label: "Token moved into the router",
value: "USDT (" + USDT + ")",
address: USDT,
isToken: true,
});
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
},
);
test.each([
{ kind: "from the router's balance", payerIsUser: false },
{ kind: "from the user's", payerIsUser: true },
])(
"a move of 2,000 USDT into the router, then a swap of 1 USDT $kind paid to the user",
({ payerIsUser }) => {
const result = decode(
execute(
permit2TransferFrom(USDT, ADDRESS_THIS, USDT_2000),
v3SwapExactIn(
MSG_SENDER,
1000000n,
1n,
USDT,
WETH,
payerIsUser,
),
),
);
expect(detail(result, "Amount").value).toBe("1.0000 USDT");
expect(paidTo(result)).toEqual([movedIntoRouter("2000.0000 USDT")]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeDefined();
},
);
test("a move into the router of a token the wallet does not know gives the token's address", () => {
const result = decode(
execute(
permit2TransferFrom(UNKNOWN_TOKEN, ADDRESS_THIS, 5n),
swapToWeth(MSG_SENDER),
),
);
expect(paidTo(result)).toEqual([
movedIntoRouter("5 base units (decimals unknown)"),
]);
expect(detail(result, "Token moved into the router")).toEqual({
label: "Token moved into the router",
value: UNKNOWN_TOKEN,
address: UNKNOWN_TOKEN,
isToken: true,
});
});
test("UniversalRouterV1 pays a PERMIT2_TRANSFER_FROM to address(1) or address(2) as written, and maps a TRANSFER's", () => {
for (const r of [MSG_SENDER, ADDRESS_THIS]) {
const result = decode(movedThenSwapped(r), USER, ROUTER_V1);
expect(paidTo(result)).toEqual([r]);
expect(detail(result, "Min. received")).toBeUndefined();
}
const transferred = decode(
execute(
swapToWeth(ADDRESS_THIS),
transfer(0x05, WETH, MSG_SENDER, CONTRACT_BALANCE),
),
USER,
ROUTER_V1,
);
expect(labels(transferred)).toEqual(PAID_TO_USER);
});
test("TAKE_PORTION does not set Token Out: a TAKE_PORTION of another currency after the TAKE_ALL of the bought one", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
takeAll(USDC),
takePortion(USDT, OTHER, 25n),
),
),
);
expect(detail(result, "Token Out").address).toBe(USDC);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
});
test("a router without command 0x07 does not read it as PAY_PORTION_FULL_PRECISION", () => {
const result = decode(
execute(
swapToWeth(MSG_SENDER),
transfer(0x07, WETH, MSG_SENDER, 10n ** 18n),
),
);
expect(detail(result, "Steps").value).toBe(
"V3 Swap (Exact In) → Command 0x07",
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. received")).toBeUndefined();
});
});
// A payout of only part of what the router holds leaves the rest there.
describe("a payout of part of the router's output", () => {
test.each([
{
kind: "a TRANSFER of 1 base unit",
step: transfer(0x05, WETH, MSG_SENDER, 1n),
},
{
kind: "a PAY_PORTION of 5,000 bips",
step: transfer(0x06, WETH, MSG_SENDER, 5000n),
},
{
kind: "a PAY_PORTION_FULL_PRECISION of 1e18 less 1",
step: transfer(0x07, WETH, MSG_SENDER, 10n ** 18n - 1n),
},
])("$kind to the user leaves the rest in the router", ({ step }) => {
const result = decode(
execute(swapToWeth(ADDRESS_THIS), step),
USER,
ROUTER_V2_1_2,
);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
});
// The router sends ETH as written, so a TRANSFER of CONTRACT_BALANCE of
// ETH always reverts; PAY_PORTION reads the router's ETH balance.
test("a TRANSFER of CONTRACT_BALANCE of ETH pays out nothing; a PAY_PORTION of 10,000 bips of ETH pays out all of it", () => {
const ethToRouter = v4Swap(
SWAP_USDT_FOR_ETH,
SETTLE_USDT,
take(ZERO, ADDRESS_THIS),
);
const transferred = decode(
execute(
ethToRouter,
transfer(0x05, ZERO, MSG_SENDER, CONTRACT_BALANCE),
),
);
expect(detail(transferred, "Token Out").value).toBe("ETH");
expect(paidTo(transferred)).toEqual([STAYS_IN_ROUTER]);
expect(detail(transferred, "Min. received")).toBeUndefined();
const portion = decode(
execute(ethToRouter, transfer(0x06, ZERO, MSG_SENDER, 10000n)),
);
expect(labels(portion)).toEqual(PAID_TO_USER);
expect(detail(portion, "Min. received").value).toBe("1.0000 ETH");
});
// An interface fee: a share of the output to another address, the rest
// to the user. The swap's minimum includes the fee, so it is not what the
// user receives.
test.each([
{
kind: "PAY_PORTION of 25 bips, then SWEEP",
data: execute(
swapToWeth(ADDRESS_THIS),
transfer(0x06, WETH, OTHER, 25n),
sweep(WETH, MSG_SENDER),
),
min: "1.0000 WETH",
},
{
kind: "TRANSFER of a flat fee, then SWEEP",
data: execute(
swapToWeth(ADDRESS_THIS),
transfer(0x05, WETH, OTHER, ONE_WETH / 400n),
sweep(WETH, MSG_SENDER),
),
min: "1.0000 WETH",
},
{
kind: "PAY_PORTION of 25 bips, then UNWRAP_WETH",
data: execute(
swapToWeth(ADDRESS_THIS),
transfer(0x06, WETH, OTHER, 25n),
unwrapWeth(MSG_SENDER),
),
min: "1.0000 ETH",
},
{
kind: "V4 TAKE_PORTION of 25 bips, then TAKE_ALL",
data: execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
takePortion(USDC, OTHER, 25n),
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
])(
"a fee by $kind names the fee's recipient and calls nothing received",
({ data, min }) => {
const result = decode(data);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe(min);
},
);
});
// ACROSS_V4_DEPOSIT_V3 hands the bridge what the router holds. The bridge
// pays the deposit's recipient on another network, and refunds a deposit
// nobody fills to its depositor; the router passes both on as written.
describe("a bridge deposit", () => {
// A swap of 2,000 USDT for at least 1 WETH paid to the router, then a
// deposit of all the router's WETH.
const bridged = (depositor, recipient) =>
execute(
swapToWeth(ADDRESS_THIS),
acrossDeposit(depositor, recipient, WETH, CONTRACT_BALANCE),
);
test.each([
{
kind: "the user is its recipient and its depositor",
recipient: USER,
depositor: USER,
named: [],
},
{
kind: "another address is its recipient",
recipient: OTHER,
depositor: USER,
named: [OTHER],
},
{
kind: "another address is its depositor",
recipient: USER,
depositor: OTHER,
named: [OTHER],
},
{
kind: "address(1) is its recipient and address(2) its depositor",
recipient: MSG_SENDER,
depositor: ADDRESS_THIS,
named: [MSG_SENDER, ADDRESS_THIS],
},
])(
"when $kind, each that is not the user is named, and nothing is received",
({ recipient, depositor, named }) => {
const result = decode(
bridged(depositor, recipient),
USER,
ROUTER_V2_1_2,
);
expect(detail(result, "Steps").value).toBe(
"V3 Swap (Exact In) → Across Bridge Deposit",
);
expect(paidTo(result)).toEqual([...named, PAID_TO_BRIDGE]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
},
);
test("a deposit of part of the router's WETH leaves the rest there", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
acrossDeposit(USER, USER, WETH, ONE_WETH / 2n),
),
USER,
ROUTER_V2_1_2,
);
expect(paidTo(result)).toEqual([PAID_TO_BRIDGE, STAYS_IN_ROUTER]);
});
test("a deposit of all the router's ETH pays out the ETH it holds; one of all its WETH does not", () => {
const ethToRouter = v4Swap(
SWAP_USDT_FOR_ETH,
SETTLE_USDT,
take(ZERO, ADDRESS_THIS),
);
const native = decode(
execute(
ethToRouter,
acrossDeposit(USER, USER, WETH, CONTRACT_BALANCE, true),
),
USER,
ROUTER_V2_1_2,
);
expect(detail(native, "Token Out").value).toBe("ETH");
expect(paidTo(native)).toEqual([PAID_TO_BRIDGE]);
const weth = decode(
execute(
ethToRouter,
acrossDeposit(USER, USER, WETH, CONTRACT_BALANCE),
),
USER,
ROUTER_V2_1_2,
);
expect(paidTo(weth)).toEqual([PAID_TO_BRIDGE, STAYS_IN_ROUTER]);
});
});
// Uniswap's mixed routes pay a V2 pair directly when the next step swaps
// through it. That V2 swap spends what the pair holds: its amountIn is
// CONTRACT_BALANCE, paid from the router, or ALREADY_PAID. The pairs below
// are the addresses the routers find for the first two tokens of that V2
// swap's path.
describe("a swap paid to the pair the next V2 swap spends", () => {
// The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
const AMOUNTS_IN = [
{ kind: "CONTRACT_BALANCE", amountIn: CONTRACT_BALANCE },
{ kind: "ALREADY_PAID", amountIn: 0n },
];
const swapTo = (recipient) =>
v3SwapExactIn(recipient, USDT_2000, 0n, USDT, WETH);
const swapToPair = swapTo(PAIR);
const pairSpends = (amountIn, path) =>
v2Swap(0x08, MSG_SENDER, amountIn, USDC_1990, path, false);
test.each(AMOUNTS_IN)(
"a V3 swap, then a V2 swap whose amountIn is $kind, reads as a swap paid to the user",
({ amountIn }) => {
const result = decode(
execute(swapToPair, pairSpends(amountIn, [WETH, USDC])),
);
expect(result.name).toBe("Swap USDT → USDC");
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Amount").value).toBe("2000.0000 USDT");
expect(detail(result, "Min. received").value).toBe(
"1990.0000 USDC",
);
},
);
test.each(AMOUNTS_IN)(
"a V3 swap paid to another address, then a V2 swap whose amountIn is $kind, names that address",
({ amountIn }) => {
const result = decode(
execute(swapTo(OTHER), pairSpends(amountIn, [WETH, USDC])),
);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe(
"1990.0000 USDC",
);
},
);
test.each([
{ recipient: PAIR, lines: PAID_TO_USER, paid: [] },
{ recipient: OTHER, lines: PAID_ELSEWHERE, paid: [OTHER] },
])(
"a V4 step whose TAKE pays $recipient, then the V2 swap",
({ recipient, lines, paid }) => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, recipient),
),
v2Swap(
0x08,
MSG_SENDER,
CONTRACT_BALANCE,
ONE_WETH,
[USDC, WETH],
false,
),
),
);
expect(labels(result)).toEqual(lines);
expect(paidTo(result)).toEqual(paid);
},
);
test.each([
{
kind: "ETH wrapped into the router, then a TRANSFER of all of it",
pay: (r) => [
wrapEth(ADDRESS_THIS, ONE_WETH),
transfer(0x05, WETH, r, CONTRACT_BALANCE),
],
},
{
kind: "a PERMIT2_TRANSFER_FROM of the user's WETH",
pay: (r) => [permit2TransferFrom(WETH, r, ONE_WETH)],
},
])(
"$kind, paid to the pair, is a hop; paid to another address, it is named",
({ pay }) => {
const route = (r) =>
execute(...pay(r), pairSpends(0n, [WETH, USDC]));
const toPair = decode(route(PAIR));
expect(paidTo(toPair)).toEqual([]);
expect(detail(toPair, "Min. received").value).toBe(
"1990.0000 USDC",
);
const toOther = decode(route(OTHER));
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. received")).toBeUndefined();
},
);
describe("on Sepolia", () => {
const SEPOLIA = "0xaa36a7";
// UniversalRouterV2_1_2 on Sepolia.
const SEPOLIA_ROUTER = "0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3";
// The WETH/USDC pair the routers on Sepolia find. Sepolia's V2
// factory reports another address for it, and no contract sits at
// this one (see V2_FACTORIES in src/shared/uniswap.js).
const SEPOLIA_PAIR = "0x541b50d32B73E769ac42F0FddD86D15FcAa861Fb";
const SEPOLIA_UNI = "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984";
const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
const SEPOLIA_USDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238";
// UNI for WETH, paid to `recipient`, then WETH for USDC through the
// pair.
const route = (recipient, amountIn) =>
execute(
v3SwapExactIn(
recipient,
ONE_WETH,
0n,
SEPOLIA_UNI,
SEPOLIA_WETH,
),
v2Swap(
0x08,
MSG_SENDER,
amountIn,
USDC_1990,
[SEPOLIA_WETH, SEPOLIA_USDC],
false,
),
);
test.each(AMOUNTS_IN)(
"paid to the pair, then a V2 swap whose amountIn is $kind, reads as a swap paid to the user",
({ amountIn }) => {
const result = uniswap.decode(
route(SEPOLIA_PAIR, amountIn),
SEPOLIA_ROUTER,
SEPOLIA,
{},
USER,
);
expect(labels(result)).toEqual(PAID_TO_USER);
},
);
test.each(AMOUNTS_IN)(
"paid to another address, then a V2 swap whose amountIn is $kind, names that address",
({ amountIn }) => {
const result = uniswap.decode(
route(OTHER, amountIn),
SEPOLIA_ROUTER,
SEPOLIA,
{},
USER,
);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([OTHER]);
},
);
test("the same route on Ethereum Mainnet names the Sepolia pair", () => {
const result = decode(route(SEPOLIA_PAIR, 0n));
expect(paidTo(result)).toEqual([SEPOLIA_PAIR]);
});
});
test.each([
{
kind: "takes its input from the user",
next: v2Swap(0x08, MSG_SENDER, ONE_WETH, USDC_1990, [WETH, USDC]),
},
{
kind: "spends another token",
next: pairSpends(0n, [USDC, WETH]),
},
])("when the V2 swap $kind, the pair is named", ({ next }) => {
const result = decode(execute(swapToPair, next));
expect(paidTo(result)).toEqual([PAIR]);
expect(detail(result, "Min. received")).toBeUndefined();
});
});
describe("every step's payee counts, whichever step sets the output", () => {
test("TAKE_ALL pays the user; a TAKE to another address beside it is named", () => {
const toUser = decode(
execute(v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))),
);
expect(labels(toUser)).toEqual(PAID_TO_USER);
// TAKE with an amount of zero takes the whole open credit, so
// TAKE_ALL after it pays the user nothing.
const split = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, OTHER),
takeAll(USDC),
),
),
);
expect(paidTo(split)).toEqual([OTHER]);
expect(detail(split, "Min. received")).toBeUndefined();
expect(detail(split, "Min. paid out").value).toBe("1990.0000 USDC");
});
test("a swap paid to another address, then a V4 step that only takes the same token", () => {
const emptyStep = v4Swap(takeAll(WETH));
const result = decode(execute(swapToWeth(OTHER), emptyStep));
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. received")).toBeUndefined();
const checked = decode(
execute(swapToWeth(OTHER), emptyStep, balanceCheck(WETH, ONE_WETH)),
);
expect(paidTo(checked)).toEqual([OTHER]);
expect(detail(checked, "Min. received")).toBeUndefined();
expect(detail(checked, "Min. paid out").value).toBe("1.0000 WETH");
});
test("a swap paid to the router, a V4 step that only takes, then SWEEP to another address", () => {
const result = decode(
execute(
v3SwapExactIn(ADDRESS_THIS, USDT_2000, USDC_1990, USDT, USDC),
v4Swap(takeAll(USDC)),
sweep(USDC, OTHER),
),
);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. received")).toBeUndefined();
});
test("a swap paid to another address, then a small swap paid to the user", () => {
const result = decode(
execute(
swapToWeth(OTHER),
v2Swap(0x08, MSG_SENDER, 1000000n, 1n, [USDT, WETH]),
),
);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeDefined();
});
test("one V4 step: a TAKE of what it buys to another address, then TAKE_ALL of another currency", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, OTHER),
takeAll(USDT),
),
),
);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. received")).toBeUndefined();
});
});
describe("a payout that cannot be read is never said to be received", () => {
test.each([
{ kind: "TAKE", action: take(USDC, OTHER) },
{ kind: "TAKE_PORTION", action: takePortion(USDC, OTHER, 25n) },
])("a V4 $kind whose recipient word has its top byte set", ({ action }) => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
withDirtyWord(action, 1),
takeAll(USDC),
),
),
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
});
test.each([
{
kind: "V2_SWAP_EXACT_IN",
step: v2Swap(0x08, OTHER, 1n, 1n, [USDT, WETH]),
},
{
kind: "V2_SWAP_EXACT_OUT",
step: v2Swap(0x09, OTHER, 1n, 1n, [USDT, WETH]),
},
{ kind: "V3_SWAP_EXACT_IN", step: swapToWeth(OTHER) },
{ kind: "SWEEP", step: sweep(WETH, OTHER), word: 1 },
{ kind: "WRAP_ETH", step: wrapEth(OTHER, 1n) },
{ kind: "UNWRAP_WETH", step: unwrapWeth(OTHER) },
{
kind: "TRANSFER",
step: transfer(0x05, WETH, OTHER, 1n),
word: 1,
},
{
kind: "PAY_PORTION",
step: transfer(0x06, WETH, OTHER, 1n),
word: 1,
},
{
kind: "PAY_PORTION_FULL_PRECISION",
step: transfer(0x07, WETH, OTHER, 1n),
word: 1,
},
{
kind: "PERMIT2_TRANSFER_FROM",
step: permit2TransferFrom(WETH, OTHER, 1n),
word: 1,
},
{
kind: "ACROSS_V4_DEPOSIT_V3",
step: acrossDeposit(USER, OTHER, WETH, 1n),
word: 2,
},
])(
"a $kind whose recipient word has its top byte set",
({ step, word = 0 }) => {
const result = decode(
execute(swapToWeth(MSG_SENDER), withDirtyWord(step, word)),
USER,
ROUTER_V2_1_2,
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeDefined();
},
);
// The router reads only the low 32 bits of the action list's length, so
// the step still runs, and may pay anyone.
test("a V4_SWAP whose action list's length word has its top byte set", () => {
const step = v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, OTHER));
const result = decode(
execute(swapToWeth(MSG_SENDER), withDirtyWord(step, 2)),
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
});
test("an output set only by BALANCE_CHECK_ERC20", () => {
const result = decode(execute(balanceCheck(WETH, ONE_WETH)));
expect(paidTo(result)).toEqual([UNNAMED]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
});
// Each is a payout this decoder does not read.
test.each([
{
kind: "PERMIT2_TRANSFER_FROM_BATCH",
step: [
0x0d,
coder.encode(
["tuple(address,address,uint160,address)[]"],
[[[USER, OTHER, 1n, WETH]]],
),
],
},
{
kind: "V3_SWAP_EXACT_OUT",
step: [0x01, swapToWeth(OTHER)[1]],
},
{
kind: "EXECUTE_SUB_PLAN",
step: [
0x21,
coder.encode(
["bytes", "bytes[]"],
["0x05", [transfer(0x05, WETH, OTHER, 1n)[1]]],
),
],
},
])("a swap paid to the user, then $kind", ({ step }) => {
const result = decode(
execute(swapToWeth(MSG_SENDER), step),
USER,
ROUTER_V2_1_2,
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. received")).toBeUndefined();
});
});
describe("paths whose recipients were untested", () => {
test("BALANCE_CHECK_ERC20 after a swap leaves who is paid as the swap says", () => {
const check = balanceCheck(WETH, ONE_WETH);
const toUser = decode(execute(swapToWeth(MSG_SENDER), check));
expect(paidTo(toUser)).toEqual([]);
expect(detail(toUser, "Min. received").value).toBe("1.0000 WETH");
const toOther = decode(execute(swapToWeth(OTHER), check));
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe("1.0000 WETH");
});
test("a V4 step whose swap action cannot be read still has its TAKE read", () => {
const step = (r) => v4Swap(UNREADABLE_SWAP, SETTLE_USDT, take(USDC, r));
const toUser = decode(execute(step(MSG_SENDER)));
expect(detail(toUser, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(paidTo(toUser)).toEqual([]);
expect(paidTo(decode(execute(step(OTHER))))).toEqual([OTHER]);
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/602: the top bit of a command
// byte (0x80) is the allow-revert flag. The router lets a step with it fail
// and goes on to the next step only for some commands, none of them a swap or
// a payment; on every other command the flag has no effect.
describe("the allow-revert flag", () => {
// UniversalRouterV1_2_V2Support on Ethereum Mainnet.
const ROUTER_V1_2 = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD";
// UniversalRouterV2_1_1 on Ethereum Mainnet.
const ROUTER_V2_1_1 = "0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA";
// The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
// `step` with the allow-revert flag set on its command byte.
const flagged = ([command, input]) => [command | 0x80, input];
// `steps` with the flag set on the one at `index`.
const flagStep = (steps, index) =>
steps.map((s, i) => (i === index ? flagged(s) : s));
const usdcToRouter = v3SwapExactIn(
ADDRESS_THIS,
USDT_2000,
USDC_1990,
USDT,
USDC,
);
const wethToPair = v3SwapExactIn(PAIR, USDT_2000, 0n, USDT, WETH);
const pairSpends = v2Swap(
0x08,
MSG_SENDER,
0n,
USDC_1990,
[WETH, USDC],
false,
);
// The flag is set on the step at `index`: a swap, a payment, or the V2
// swap that a payment to its pair hops into.
test.each([
{
kind: "V2_SWAP_EXACT_IN",
steps: [
v2Swap(0x08, MSG_SENDER, USDT_2000, ONE_WETH, [USDT, WETH]),
],
index: 0,
},
{
kind: "V2_SWAP_EXACT_OUT",
steps: [
v2Swap(0x09, MSG_SENDER, ONE_WETH, USDT_2000, [USDT, WETH]),
],
index: 0,
},
{
kind: "V3_SWAP_EXACT_IN",
steps: [swapToWeth(MSG_SENDER)],
index: 0,
},
{
kind: "V4_SWAP",
steps: [v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))],
index: 0,
},
{
kind: "SWEEP",
steps: [usdcToRouter, sweep(USDC, MSG_SENDER)],
index: 1,
},
{
kind: "TRANSFER",
steps: [
usdcToRouter,
transfer(0x05, USDC, MSG_SENDER, CONTRACT_BALANCE),
],
index: 1,
},
{
kind: "PAY_PORTION",
steps: [usdcToRouter, transfer(0x06, USDC, MSG_SENDER, 10000n)],
index: 1,
},
{
kind: "PAY_PORTION_FULL_PRECISION",
steps: [usdcToRouter, transfer(0x07, USDC, MSG_SENDER, 10n ** 18n)],
index: 1,
},
{
kind: "UNWRAP_WETH",
steps: [swapToWeth(ADDRESS_THIS), unwrapWeth(MSG_SENDER)],
index: 1,
},
{
kind: "WRAP_ETH",
steps: [
wrapEth(ADDRESS_THIS, ONE_WETH),
v3SwapExactIn(
MSG_SENDER,
ONE_WETH,
USDC_1990,
WETH,
USDC,
false,
),
],
index: 0,
},
{
kind: "ACROSS_V4_DEPOSIT_V3",
steps: [
usdcToRouter,
acrossDeposit(USER, USER, USDC, CONTRACT_BALANCE),
],
index: 1,
},
{
kind: "PERMIT2_TRANSFER_FROM to the pair the next V2 swap spends",
steps: [permit2TransferFrom(WETH, PAIR, ONE_WETH), pairSpends],
index: 0,
},
{
kind: "a swap paid to the pair the next V2 swap spends",
steps: [wethToPair, pairSpends],
index: 0,
},
{
kind: "the V2 swap after a swap paid to its pair",
steps: [wethToPair, pairSpends],
index: 1,
},
])(
"$kind reads the same with the flag as without it",
({ steps, index }) => {
const plain = decode(execute(...steps), USER, ROUTER_V2_1_2);
const withFlag = decode(
execute(...flagStep(steps, index)),
USER,
ROUTER_V2_1_2,
);
expect(withFlag).toEqual(plain);
expect(detail(withFlag, "Steps").value).not.toContain(
"allowed to fail",
);
},
);
test.each([
{ router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 },
{ router: "UniversalRouterV2", address: ROUTER },
{ router: "UniversalRouterV2_1_2", address: ROUTER_V2_1_2 },
])(
"on $router, a BALANCE_CHECK_ERC20 with the flag is allowed to fail and does not replace the swap's minimum",
({ address }) => {
const check = balanceCheck(WETH, 2n * ONE_WETH);
const plain = decode(
execute(swapToWeth(MSG_SENDER), check),
USER,
address,
);
expect(detail(plain, "Steps").value).toBe(
"V3 Swap (Exact In) → Balance Check",
);
expect(detail(plain, "Min. received").value).toBe("2.0000 WETH");
const withFlag = decode(
execute(swapToWeth(MSG_SENDER), flagged(check)),
USER,
address,
);
expect(detail(withFlag, "Steps").value).toBe(
"V3 Swap (Exact In) → Balance Check (allowed to fail)",
);
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
},
);
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, states none", () => {
const check = balanceCheck(WETH, ONE_WETH);
expect(detail(decode(execute(check)), "Min. paid out").value).toBe(
"1.0000 WETH",
);
const withFlag = decode(execute(flagged(check)));
expect(detail(withFlag, "Token Out").address).toBe(WETH);
expect(detail(withFlag, "Min. paid out").value).toBe(
"None (no minimum guaranteed)",
);
});
// On these two routers 0x10 is an NFT market call the router lets fail,
// still read as a V4 swap
// (https://git.eeqj.de/sneak/AutistMask/issues/592).
test.each([
{ router: "UniversalRouterV1", address: ROUTER_V1 },
{ router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 },
])(
"on $router, a V4_SWAP with the flag is allowed to fail and states no minimum",
({ address }) => {
const step = v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC));
const plain = decode(execute(step), USER, address);
expect(detail(plain, "Steps").value).toBe("V4 Swap");
expect(detail(plain, "Min. received").value).toBe("1990.0000 USDC");
const withFlag = decode(execute(flagged(step)), USER, address);
expect(detail(withFlag, "Steps").value).toBe(
"V4 Swap (allowed to fail)",
);
expect(detail(withFlag, "Token Out").address).toBe(USDC);
expect(detail(withFlag, "Min. received").value).toBe(
"None (no minimum guaranteed)",
);
},
);
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
test.each([
{ router: "UniversalRouterV1", address: ROUTER_V1, mark: "" },
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
mark: "",
},
{
router: "UniversalRouterV2",
address: ROUTER,
mark: " (allowed to fail)",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2,
mark: " (allowed to fail)",
},
])(
"on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap reads as without the flag",
({ address, mark }) => {
const steps = [
permit2Permit(USDT, USDT_2000),
swapToWeth(MSG_SENDER),
];
const plain = decode(execute(...steps), USER, address);
const withFlag = decode(
execute(...flagStep(steps, 0)),
USER,
address,
);
expect(detail(withFlag, "Steps").value).toBe(
"Permit2 Permit" + mark + " → V3 Swap (Exact In)",
);
const withoutSteps = (r) =>
r.details.filter((d) => d.label !== "Steps");
expect(withoutSteps(withFlag)).toEqual(withoutSteps(plain));
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
},
);
test("a step the decoder does not read is marked too", () => {
const positionManagerCall = [0x12, "0x"];
const plain = decode(
execute(swapToWeth(MSG_SENDER), positionManagerCall),
);
expect(detail(plain, "Steps").value).toBe(
"V3 Swap (Exact In) → V3 Position Mgr Call",
);
const withFlag = decode(
execute(swapToWeth(MSG_SENDER), flagged(positionManagerCall)),
);
expect(detail(withFlag, "Steps").value).toBe(
"V3 Swap (Exact In) → V3 Position Mgr Call (allowed to fail)",
);
expect(paidTo(withFlag)).toEqual([UNDECODED]);
});
// A command from each router's list, by its name in that release's
// Commands.sol.
test.each([
{
router: "UniversalRouterV1",
address: ROUTER_V1,
command: "FOUNDATION",
id: 0x1c,
name: "Command 0x1c",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
command: "ELEMENT_MARKET",
id: 0x1e,
name: "Command 0x1e",
},
{
router: "UniversalRouterV2",
address: ROUTER,
command: "PERMIT2_PERMIT_BATCH",
id: 0x03,
name: "Permit2 Permit Batch",
},
{
router: "UniversalRouterV2_1_1",
address: ROUTER_V2_1_1,
command: "V4_INITIALIZE_POOL",
id: 0x13,
name: "V4 Initialize Pool",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2,
command: "V4_POSITION_MANAGER_CALL",
id: 0x14,
name: "V4 Position Mgr Call",
},
])(
"on $router, $command with the flag is listed as '$name (allowed to fail)'",
({ address, id, name }) => {
const step = [id, "0x"];
const plain = decode(
execute(swapToWeth(MSG_SENDER), step),
USER,
address,
);
expect(detail(plain, "Steps").value).toBe(
"V3 Swap (Exact In) → " + name,
);
const withFlag = decode(
execute(swapToWeth(MSG_SENDER), flagged(step)),
USER,
address,
);
expect(detail(withFlag, "Steps").value).toBe(
"V3 Swap (Exact In) → " + name + " (allowed to fail)",
);
},
);
});
// The DOM stub both screens are drawn into.
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
style: {},
dataset: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: () => false,
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
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);
}
// A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`.
const swapPaidTo = (recipient) => execute(swapToWeth(recipient));
// Open the approval screen for a transaction from USER sending `data` to the
// router.
async function showApproval(data) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (_msg, reply) =>
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: USER,
approvedTx: {
type: 2,
from: USER,
chainId: "0x1",
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: ROUTER,
value: "0x0",
data,
accessList: [],
},
}),
};
state.networkId = "mainnet";
state.currentView = null;
await approval.show(1);
return node("approve-tx-decoded-details").innerHTML;
}
// The transaction the explorer reports: from USER, sending `mockSentData` to
// the router.
const TX_HASH = "0x" + "5".repeat(64);
let mockSentData = null;
function mockExplorerAnswer(url) {
if (url.endsWith("/transactions/" + TX_HASH)) {
return {
from: { hash: USER },
to: { hash: ROUTER },
raw_input: mockSentData,
method: "execute",
};
}
return {};
}
// Open the transaction detail screen for that transaction, sending `data`.
async function showDetail(data) {
mockSentData = data;
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.networkId = "mainnet";
state.blockscoutUrl = "https://explorer.example/api/v2";
state.viewData = {};
state.viewStack = [];
state.currentView = null;
transactionDetail.show({
hash: TX_HASH,
from: USER,
to: ROUTER,
value: "",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
direction: "contract",
directionLabel: "Swap",
method: "execute",
chainId: "0x1",
});
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
return node("tx-detail-calldata-details").innerHTML;
}
describe("the screens judge the output against the transaction's sender", () => {
test("the approval screen, against its From address", async () => {
const toUser = await showApproval(swapPaidTo(USER));
expect(toUser).toContain("Min. received");
expect(toUser).not.toContain("Paid to");
const toOther = await showApproval(swapPaidTo(OTHER));
expect(toOther).toContain("Paid to");
expect(toOther).toContain(OTHER);
expect(toOther).not.toContain("Min. received");
});
test("the transaction detail screen, against the sender the explorer reports", async () => {
const toUser = await showDetail(swapPaidTo(USER));
expect(toUser).toContain("Min. received");
expect(toUser).not.toContain("Paid to");
const toOther = await showDetail(swapPaidTo(OTHER));
expect(toOther).toContain("Paid to");
expect(toOther).not.toContain("Min. received");
});
});
// The address of each token moved into the router, a known one and one the
// wallet does not know, is shown as every other address is: with an explorer
// link, and copied when clicked. Neither token is the swap's.
describe("the screens show each token moved into the router as an address", () => {
const twoMovesThenSwap = execute(
permit2TransferFrom(USDC, ADDRESS_THIS, USDC_1990),
permit2TransferFrom(UNKNOWN_TOKEN, ADDRESS_THIS, 5n),
swapToWeth(MSG_SENDER),
);
test.each([
{ screen: "approval", show: showApproval },
{ screen: "transaction detail", show: showDetail },
])("the $screen screen", async ({ show }) => {
const html = await show(twoMovesThenSwap);
for (const token of [USDC, UNKNOWN_TOKEN]) {
expect(html).toContain('data-copy="' + token + '"');
expect(html).toContain("/address/" + token + '"');
}
});
});