Each Universal Router release now carries the command it runs for each command type, by the name its pinned Commands.sol gives it, and the decoder names and reads each step as that command instead of using one table for every router. On UniversalRouterV1 and UniversalRouterV1_2_V2Support, 0x10 to 0x13 are NFT commands, such as Seaport: they are no longer read as V4 commands, so they draw no swap lines and their payouts count as unread. UniversalRouterV1 has no BALANCE_CHECK_ERC20 and reverts on 0x0e, which is now listed as Command 0x0e and sets no minimum. No release lets a V4 swap fail, so the zero-minimum reading for a flagged one is gone. Model: opus-5-5
2262 lines
76 KiB
JavaScript
2262 lines
76 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), the allow-revert flag
|
|
// on the steps that pay it
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/602), the command each router
|
|
// release runs for a command type
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/592), and the address whose
|
|
// balance a BALANCE_CHECK_ERC20 checks
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/600).
|
|
//
|
|
// 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";
|
|
// UniversalRouterV1_2_V2Support on Ethereum Mainnet.
|
|
const ROUTER_V1_2 = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD";
|
|
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])];
|
|
}
|
|
|
|
// BALANCE_CHECK_ERC20 (0x0e) of `owner`'s balance of `token`.
|
|
function balanceCheck(token, minBalance, owner = USER) {
|
|
return [
|
|
0x0e,
|
|
coder.encode(
|
|
["address", "address", "uint256"],
|
|
[owner, token, minBalance],
|
|
),
|
|
];
|
|
}
|
|
|
|
// `step` with the allow-revert flag set on its command byte.
|
|
const flagged = ([command, input]) => [command | 0x80, input];
|
|
|
|
// `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", () => {
|
|
// 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";
|
|
|
|
// `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)",
|
|
);
|
|
});
|
|
|
|
// 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: "Foundation",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1_2_V2Support",
|
|
address: ROUTER_V1_2,
|
|
command: "ELEMENT_MARKET",
|
|
id: 0x1e,
|
|
name: "Element Market",
|
|
},
|
|
{
|
|
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)",
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/592: each release runs its own
|
|
// command for a command type, as its Commands.sol names it. The decoder named
|
|
// and read every type as UniversalRouterV2 does, so on the two older routers
|
|
// an NFT command read as a V4 swap, and on UniversalRouterV1, which reverts on
|
|
// 0x0e, that type read as a balance check that set the minimum.
|
|
describe("the command each release runs for a command type", () => {
|
|
// Inputs UniversalRouterV2 reads as a V4 swap of 2,000 USDT for at least
|
|
// 1,990 USDC paid to the user, and as a check that the user holds at
|
|
// least 1 WETH.
|
|
const swapInput = v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))[1];
|
|
const checkInput = balanceCheck(WETH, ONE_WETH)[1];
|
|
// The lines of a transaction whose one step the decoder does not read.
|
|
const NOT_READ = [
|
|
"Protocol",
|
|
"Token In",
|
|
"Token Out",
|
|
"Paid to",
|
|
"Steps",
|
|
"Deadline",
|
|
];
|
|
|
|
test.each([
|
|
{
|
|
router: "UniversalRouterV1",
|
|
address: ROUTER_V1,
|
|
id: 0x0e,
|
|
input: checkInput,
|
|
name: "Command 0x0e",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1",
|
|
address: ROUTER_V1,
|
|
id: 0x10,
|
|
input: swapInput,
|
|
name: "Seaport",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1",
|
|
address: ROUTER_V1,
|
|
id: 0x11,
|
|
input: swapInput,
|
|
name: "LooksRare 721",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1",
|
|
address: ROUTER_V1,
|
|
id: 0x12,
|
|
input: swapInput,
|
|
name: "NFTX",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1",
|
|
address: ROUTER_V1,
|
|
id: 0x13,
|
|
input: swapInput,
|
|
name: "CryptoPunks",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1",
|
|
address: ROUTER_V1,
|
|
id: 0x14,
|
|
input: swapInput,
|
|
name: "LooksRare 1155",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1",
|
|
address: ROUTER_V1,
|
|
id: 0x15,
|
|
input: swapInput,
|
|
name: "Owner Check 721",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1",
|
|
address: ROUTER_V1,
|
|
id: 0x16,
|
|
input: swapInput,
|
|
name: "Owner Check 1155",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1",
|
|
address: ROUTER_V1,
|
|
id: 0x17,
|
|
input: swapInput,
|
|
name: "Sweep ERC-721",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1",
|
|
address: ROUTER_V1,
|
|
id: 0x18,
|
|
input: swapInput,
|
|
name: "X2Y2 721",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1",
|
|
address: ROUTER_V1,
|
|
id: 0x19,
|
|
input: swapInput,
|
|
name: "Sudoswap",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1",
|
|
address: ROUTER_V1,
|
|
id: 0x1a,
|
|
input: swapInput,
|
|
name: "NFT20",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1",
|
|
address: ROUTER_V1,
|
|
id: 0x1b,
|
|
input: swapInput,
|
|
name: "X2Y2 1155",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1",
|
|
address: ROUTER_V1,
|
|
id: 0x1c,
|
|
input: swapInput,
|
|
name: "Foundation",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1",
|
|
address: ROUTER_V1,
|
|
id: 0x1d,
|
|
input: swapInput,
|
|
name: "Sweep ERC-1155",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1_2_V2Support",
|
|
address: ROUTER_V1_2,
|
|
id: 0x10,
|
|
input: swapInput,
|
|
name: "Seaport V1.5",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1_2_V2Support",
|
|
address: ROUTER_V1_2,
|
|
id: 0x11,
|
|
input: swapInput,
|
|
name: "LooksRare V2",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1_2_V2Support",
|
|
address: ROUTER_V1_2,
|
|
id: 0x12,
|
|
input: swapInput,
|
|
name: "NFTX",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1_2_V2Support",
|
|
address: ROUTER_V1_2,
|
|
id: 0x13,
|
|
input: swapInput,
|
|
name: "CryptoPunks",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1_2_V2Support",
|
|
address: ROUTER_V1_2,
|
|
id: 0x14,
|
|
input: swapInput,
|
|
name: "Command 0x14",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1_2_V2Support",
|
|
address: ROUTER_V1_2,
|
|
id: 0x15,
|
|
input: swapInput,
|
|
name: "Owner Check 721",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1_2_V2Support",
|
|
address: ROUTER_V1_2,
|
|
id: 0x16,
|
|
input: swapInput,
|
|
name: "Owner Check 1155",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1_2_V2Support",
|
|
address: ROUTER_V1_2,
|
|
id: 0x17,
|
|
input: swapInput,
|
|
name: "Sweep ERC-721",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1_2_V2Support",
|
|
address: ROUTER_V1_2,
|
|
id: 0x18,
|
|
input: swapInput,
|
|
name: "X2Y2 721",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1_2_V2Support",
|
|
address: ROUTER_V1_2,
|
|
id: 0x19,
|
|
input: swapInput,
|
|
name: "Sudoswap",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1_2_V2Support",
|
|
address: ROUTER_V1_2,
|
|
id: 0x1a,
|
|
input: swapInput,
|
|
name: "NFT20",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1_2_V2Support",
|
|
address: ROUTER_V1_2,
|
|
id: 0x1b,
|
|
input: swapInput,
|
|
name: "X2Y2 1155",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1_2_V2Support",
|
|
address: ROUTER_V1_2,
|
|
id: 0x1c,
|
|
input: swapInput,
|
|
name: "Foundation",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1_2_V2Support",
|
|
address: ROUTER_V1_2,
|
|
id: 0x1d,
|
|
input: swapInput,
|
|
name: "Sweep ERC-1155",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1_2_V2Support",
|
|
address: ROUTER_V1_2,
|
|
id: 0x1e,
|
|
input: swapInput,
|
|
name: "Element Market",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1_2_V2Support",
|
|
address: ROUTER_V1_2,
|
|
id: 0x20,
|
|
input: swapInput,
|
|
name: "Seaport V1.4",
|
|
},
|
|
{
|
|
router: "UniversalRouterV1_2_V2Support",
|
|
address: ROUTER_V1_2,
|
|
id: 0x22,
|
|
input: swapInput,
|
|
name: "Approve ERC-20",
|
|
},
|
|
{
|
|
router: "UniversalRouterV2",
|
|
address: ROUTER,
|
|
id: 0x11,
|
|
input: swapInput,
|
|
name: "V3 Position Mgr Permit",
|
|
},
|
|
{
|
|
router: "UniversalRouterV2",
|
|
address: ROUTER,
|
|
id: 0x12,
|
|
input: swapInput,
|
|
name: "V3 Position Mgr Call",
|
|
},
|
|
{
|
|
router: "UniversalRouterV2",
|
|
address: ROUTER,
|
|
id: 0x13,
|
|
input: swapInput,
|
|
name: "V4 Initialize Pool",
|
|
},
|
|
])(
|
|
"on $router, '$name' is a step the decoder does not read",
|
|
({ address, id, input, name }) => {
|
|
const result = decode(execute([id, input]), USER, address);
|
|
expect(detail(result, "Steps").value).toBe(name);
|
|
expect(result.name).toBe("Uniswap Swap");
|
|
expect(labels(result)).toEqual(NOT_READ);
|
|
expect(paidTo(result)).toEqual([UNDECODED]);
|
|
},
|
|
);
|
|
|
|
test.each([
|
|
{ router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 },
|
|
{ router: "UniversalRouterV2", address: ROUTER },
|
|
])(
|
|
"on $router, 0x0e is a balance check, which sets the minimum",
|
|
({ address }) => {
|
|
const result = decode(execute([0x0e, checkInput]), USER, address);
|
|
expect(detail(result, "Steps").value).toBe("Balance Check");
|
|
expect(detail(result, "Token Out").address).toBe(WETH);
|
|
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
|
|
},
|
|
);
|
|
|
|
test("on UniversalRouterV2, 0x10 is a V4 swap", () => {
|
|
const result = decode(execute([0x10, swapInput]), USER, ROUTER);
|
|
expect(detail(result, "Steps").value).toBe("V4 Swap");
|
|
expect(result.name).toBe("Swap USDT → USDC");
|
|
expect(detail(result, "Min. received").value).toBe("1990.0000 USDC");
|
|
});
|
|
});
|
|
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/600: on every release that has
|
|
// BALANCE_CHECK_ERC20, the router checks the balance of the owner as written,
|
|
// without map(), so address(1) and address(2) there are those addresses, not
|
|
// the sender and the router. Only a check of the sender's own balance says
|
|
// anything about what the user receives.
|
|
describe.each([
|
|
{ router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 },
|
|
{ router: "UniversalRouterV2", address: ROUTER },
|
|
{ router: "UniversalRouterV2_1_2", address: ROUTER_V2_1_2 },
|
|
])("on $router, the owner of a BALANCE_CHECK_ERC20", ({ address }) => {
|
|
// A swap of 2,000 USDT for WETH with no minimum, paid to the user.
|
|
const swap = v3SwapExactIn(MSG_SENDER, USDT_2000, 0n, USDT, WETH);
|
|
const NO_MINIMUM = "None (no minimum guaranteed)";
|
|
|
|
test("the sender: the check sets the minimum, unless the router lets it fail", () => {
|
|
const check = balanceCheck(WETH, ONE_WETH, USER);
|
|
|
|
const plain = decode(execute(swap, check), USER, address);
|
|
expect(detail(plain, "Steps").value).toBe(
|
|
"V3 Swap (Exact In) → Balance Check",
|
|
);
|
|
expect(detail(plain, "Min. received").value).toBe("1.0000 WETH");
|
|
// The sender as the From address gives it, in lower case.
|
|
expect(
|
|
decode(execute(swap, check), USER.toLowerCase(), address),
|
|
).toEqual(plain);
|
|
|
|
const withFlag = decode(execute(swap, 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(NO_MINIMUM);
|
|
});
|
|
|
|
const notTheSender = [
|
|
{ kind: "address(1)", owner: MSG_SENDER },
|
|
{ kind: "address(2)", owner: ADDRESS_THIS },
|
|
{ kind: "another address", owner: OTHER },
|
|
];
|
|
|
|
test.each(notTheSender)(
|
|
"$kind: after a swap paid to the user, the check sets no minimum and is said to be of another address",
|
|
({ owner }) => {
|
|
const check = balanceCheck(WETH, ONE_WETH, owner);
|
|
|
|
const plain = decode(execute(swap, check), USER, address);
|
|
expect(detail(plain, "Steps").value).toBe(
|
|
"V3 Swap (Exact In) → Balance Check (of another address)",
|
|
);
|
|
expect(paidTo(plain)).toEqual([]);
|
|
expect(detail(plain, "Min. received").value).toBe(NO_MINIMUM);
|
|
|
|
const withFlag = decode(
|
|
execute(swap, flagged(check)),
|
|
USER,
|
|
address,
|
|
);
|
|
expect(detail(withFlag, "Steps").value).toBe(
|
|
"V3 Swap (Exact In) → Balance Check (of another address) (allowed to fail)",
|
|
);
|
|
expect(paidTo(withFlag)).toEqual([]);
|
|
expect(detail(withFlag, "Min. received").value).toBe(NO_MINIMUM);
|
|
},
|
|
);
|
|
|
|
test.each(notTheSender)(
|
|
"$kind: a check alone names no output token and states no minimum",
|
|
({ owner }) => {
|
|
const check = balanceCheck(WETH, ONE_WETH, owner);
|
|
|
|
for (const step of [check, flagged(check)]) {
|
|
const result = decode(execute(step), USER, address);
|
|
expect(detail(result, "Token Out").value).toBe(UNNAMED);
|
|
expect(detail(result, "Min. received")).toBeUndefined();
|
|
expect(detail(result, "Min. paid out")).toBeUndefined();
|
|
}
|
|
},
|
|
);
|
|
|
|
test("an owner word with a nonzero top byte: the check is said not to decode and sets no minimum", () => {
|
|
// The router checks the balance of the address in the word's last 20
|
|
// bytes, here OTHER's.
|
|
const check = withDirtyWord(balanceCheck(WETH, ONE_WETH, OTHER), 0);
|
|
|
|
const plain = decode(execute(swap, check), USER, address);
|
|
expect(detail(plain, "Steps").value).toBe(
|
|
"V3 Swap (Exact In) → Balance Check (could not be decoded)",
|
|
);
|
|
expect(detail(plain, "Min. received").value).toBe(NO_MINIMUM);
|
|
|
|
const withFlag = decode(execute(swap, flagged(check)), USER, address);
|
|
expect(detail(withFlag, "Steps").value).toBe(
|
|
"V3 Swap (Exact In) → Balance Check (could not be decoded) (allowed to fail)",
|
|
);
|
|
expect(detail(withFlag, "Min. received").value).toBe(NO_MINIMUM);
|
|
});
|
|
});
|
|
|
|
// 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 + '"');
|
|
}
|
|
});
|
|
});
|