fix: each PERMIT2_TRANSFER_FROM shows the amount it moves and where to (closes #611)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

The swap decoder now gives three lines for every PERMIT2_TRANSFER_FROM
it reads, after the swap's Amount line: the amount moved from the
sender, the token with its address, and where it goes (the router, the
V2 pair the next swap spends, the sender, or the address itself on a
line of its own). Before, a move to another address or to the next V2
pair showed no amount at all. Who counts as paid, and so Min. received
against Min. paid out, is unchanged. tokenMovedLine() moves to module
level and takes its label as an argument.

Model: opus-5-5
This commit is contained in:
2026-10-19 20:31:32 +00:00
parent 30dca1454e
commit 66cae2e593
3 changed files with 228 additions and 36 deletions
+142 -4
View File
@@ -6,8 +6,10 @@
// release runs for a command type
// (https://git.eeqj.de/sneak/AutistMask/issues/592), the address whose
// balance a BALANCE_CHECK_ERC20 checks
// (https://git.eeqj.de/sneak/AutistMask/issues/600), and any token a swap
// leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606).
// (https://git.eeqj.de/sneak/AutistMask/issues/600), any token a swap
// leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606), and
// what each PERMIT2_TRANSFER_FROM moves
// (https://git.eeqj.de/sneak/AutistMask/issues/611).
//
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
@@ -349,6 +351,13 @@ const PAID_ELSEWHERE = [
"Steps",
"Deadline",
];
// `lines` with the lines of one PERMIT2_TRANSFER_FROM after Amount.
const withMove = (lines) =>
lines.flatMap((l) =>
l === "Amount"
? [l, "Moved from the sender", "Token moved", "Moved to"]
: [l],
);
// A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`.
const swapToWeth = (recipient) =>
@@ -1154,12 +1163,12 @@ describe("each transfer command's recipient", () => {
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(labels(result)).toEqual(withMove(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(labels(toOther)).toEqual(withMove(PAID_ELSEWHERE));
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe("1.0000 WETH");
@@ -1183,6 +1192,9 @@ describe("each transfer command's recipient", () => {
"Protocol",
"Token In",
"Amount",
"Moved from the sender",
"Token moved",
"Moved to",
"Token Out",
"Paid to",
"Token moved into the router",
@@ -1249,6 +1261,11 @@ describe("each transfer command's recipient", () => {
for (const r of [MSG_SENDER, ADDRESS_THIS]) {
const result = decode(movedThenSwapped(r), USER, ROUTER_V1);
expect(paidTo(result)).toEqual([r]);
expect(detail(result, "Moved to")).toEqual({
label: "Moved to",
value: r,
address: r,
});
expect(detail(result, "Min. received")).toBeUndefined();
}
@@ -1294,6 +1311,108 @@ describe("each transfer command's recipient", () => {
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/611: after the swap's own
// Amount, each PERMIT2_TRANSFER_FROM gives the amount of the sender's tokens
// it moves, the token, and where it goes, whoever counts as paid.
describe("what each PERMIT2_TRANSFER_FROM moves", () => {
// The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
const USDT_1 = 1000000n;
const USDT_1000000 = 1000000000000n;
// The lines between Amount and Token Out.
function afterAmount(result) {
const all = labels(result);
return result.details.slice(
all.indexOf("Amount") + 1,
all.indexOf("Token Out"),
);
}
// The lines of a move of `amount` of `token`, then `to`.
const moved = (amount, token, symbol, to) => [
{ label: "Moved from the sender", value: amount },
{
label: "Token moved",
value: symbol + " (" + token + ")",
address: token,
isToken: true,
},
to,
];
const movedTo = (value) => ({ label: "Moved to", value });
const movedToAddress = (address) => ({
label: "Moved to",
value: address,
address,
});
test("1,000,000 USDT moved to another address, then a swap of 1 USDT", () => {
const result = decode(
execute(
permit2TransferFrom(USDT, OTHER, USDT_1000000),
v3SwapExactIn(MSG_SENDER, USDT_1, ONE_WETH, USDT, WETH),
),
);
expect(detail(result, "Amount").value).toBe("1.0000 USDT");
expect(afterAmount(result)).toEqual(
moved("1000000.0000 USDT", USDT, "USDT", movedToAddress(OTHER)),
);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
});
test("1 WETH moved to the pair the next V2 swap spends", () => {
const result = decode(
execute(
permit2TransferFrom(WETH, PAIR, ONE_WETH),
v2Swap(0x08, MSG_SENDER, 0n, USDC_1990, [WETH, USDC], false),
),
);
expect(detail(result, "Amount").value).toBe(
"Whatever an earlier step sent to the pair (V2 already paid)",
);
expect(afterAmount(result)).toEqual(
moved(
"1.0000 WETH",
WETH,
"WETH",
movedTo("The V2 pair the next swap spends"),
),
);
expect(paidTo(result)).toEqual([]);
expect(detail(result, "Min. received").value).toBe("1990.0000 USDC");
});
test("2,000 USDT moved into the router, then a swap of 1 USDT from the router's balance", () => {
const result = decode(
execute(
permit2TransferFrom(USDT, ADDRESS_THIS, USDT_2000),
v3SwapExactIn(MSG_SENDER, USDT_1, ONE_WETH, USDT, WETH, false),
),
);
expect(detail(result, "Amount").value).toBe("1.0000 USDT");
expect(afterAmount(result)).toEqual(
moved("2000.0000 USDT", USDT, "USDT", movedTo("The router")),
);
expect(paidTo(result)).toEqual([movedIntoRouter("2000.0000 USDT")]);
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
});
test("a move to address(1) goes to the sender, and each move is shown in step order", () => {
const result = decode(
execute(
permit2TransferFrom(USDT, MSG_SENDER, USDT_2000),
permit2TransferFrom(USDC, OTHER, USDC_1990),
swapToWeth(MSG_SENDER),
),
);
expect(afterAmount(result)).toEqual([
...moved("2000.0000 USDT", USDT, "USDT", movedTo("The sender")),
...moved("1990.0000 USDC", USDC, "USDC", movedToAddress(OTHER)),
]);
});
});
// 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([
@@ -2787,3 +2906,22 @@ describe("the screens show each token moved into the router as an address", () =
}
});
});
// The issue's example: 1,000,000 USDT moved to another address, then a swap
// of 1 USDT. Both screens show the amount moved and where it goes.
describe("the screens show what a PERMIT2_TRANSFER_FROM moves", () => {
const millionToOther = execute(
permit2TransferFrom(USDT, OTHER, 1000000000000n),
v3SwapExactIn(MSG_SENDER, 1000000n, ONE_WETH, USDT, WETH),
);
test.each([
{ screen: "approval", show: showApproval },
{ screen: "transaction detail", show: showDetail },
])("the $screen screen", async ({ show }) => {
const html = await show(millionToOther);
expect(html).toContain("1000000.0000 USDT");
expect(html).toContain("Moved to");
expect(html).toContain('data-copy="' + OTHER + '"');
});
});