fix: a swap split across steps the user pays for shows every amount it spends (closes #625)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s

The swap's Amount line took only the first swap step's amount. Each later
step the user pays for now adds what it takes of Token In, and what it
takes of another token, or an amount the decoder cannot tell, gets lines
of its own. A V4 step counts every SETTLE paid by the user and every
SETTLE_ALL, with or without a swap action. A step that may take from the
sender what the decoder did not read leaves no Amount figure and gets one
line saying so. Only a permit to the router that the router does not let
fail still covers later takes of its token.

Model: opus-5-5
This commit is contained in:
2026-10-19 23:53:32 +00:00
parent 5376723c41
commit 08d760d195
4 changed files with 921 additions and 136 deletions
+55 -12
View File
@@ -160,8 +160,8 @@ function transfer(command, token, recipient, amount) {
];
}
// PERMIT2_PERMIT (0x0a) of `amount` of `token`, for the router to spend.
function permit2Permit(token, amount) {
// PERMIT2_PERMIT (0x0a) of `amount` of `token`, for `spender` to spend.
function permit2Permit(token, amount, spender) {
return [
0x0a,
coder.encode(
@@ -169,7 +169,7 @@ function permit2Permit(token, amount) {
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
"bytes",
],
[[[token, amount, 0, 0], ROUTER, 0n], "0x"],
[[[token, amount, 0, 0], spender, 0n], "0x"],
),
];
}
@@ -671,6 +671,7 @@ describe("any token left in the router is not received", () => {
v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC),
),
left: tokenLine(WETH, "WETH"),
spent: "2000.0000 USDT",
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a swap of 0.5 WETH from the router",
@@ -697,6 +698,7 @@ describe("any token left in the router is not received", () => {
v2Swap(0x08, MSG_SENDER, ONE_WETH, USDC_1990, [WETH, USDC]),
),
left: tokenLine(WETH, "WETH"),
spent: "1.0000 WETH",
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V3 swap of 1 WETH that the user pays for",
@@ -705,6 +707,7 @@ describe("any token left in the router is not received", () => {
v3SwapExactIn(MSG_SENDER, ONE_WETH, USDC_1990, WETH, USDC),
),
left: tokenLine(WETH, "WETH"),
spent: "1.0000 WETH",
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE of 1 WETH by the user",
@@ -717,6 +720,7 @@ describe("any token left in the router is not received", () => {
),
),
left: tokenLine(WETH, "WETH"),
spent: "1.0000 WETH",
},
{
kind: "a swap paying WETH to the router, a WRAP_ETH of 1 ETH into the router, then a swap of 1 WETH from the router",
@@ -792,9 +796,13 @@ describe("any token left in the router is not received", () => {
),
),
left: tokenLine(WETH, "WETH"),
// The step has two SETTLEs of WETH, so what the user pays is not
// known.
spent: UNDECODED,
},
// The user pays for the swap by a SETTLE_ALL or SETTLE whose currency
// the decoder cannot read, so the SETTLE from the router pays nothing.
// the decoder cannot read, so the SETTLE from the router pays nothing,
// and what the user pays is not known.
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE_ALL that cannot be read and a SETTLE of OPEN_DELTA from the router",
data: execute(
@@ -807,6 +815,7 @@ describe("any token left in the router is not received", () => {
),
),
left: tokenLine(WETH, "WETH"),
unread: true,
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE by the user that cannot be read and a SETTLE of OPEN_DELTA from the router",
@@ -820,6 +829,7 @@ describe("any token left in the router is not received", () => {
),
),
left: tokenLine(WETH, "WETH"),
unread: true,
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 step with an exact-in of 1 WETH less 1,000 wei and an empty path, a swap of 1,000 wei, and a SETTLE of OPEN_DELTA from the router",
@@ -873,12 +883,20 @@ describe("any token left in the router is not received", () => {
),
left: { label: "Token moved into the router", value: "ETH" },
},
])("$kind", ({ data, left }) => {
])("$kind", ({ data, left, spent, unread }) => {
const result = decode(data);
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
// A later step whose SETTLE or SETTLE_ALL the decoder cannot read
// may take anything from the sender: one line says so, in place
// of the Amount line (https://git.eeqj.de/sneak/AutistMask/issues/625).
unread ? "Moved from the sender" : "Amount",
// A later swap step the user pays for spends `spent` of a token
// other than Token In.
...(spent
? ["Moved from the sender", "Token moved", "Moved to"]
: []),
"Token Out",
"Paid to",
"Token moved into the router",
@@ -886,6 +904,14 @@ describe("any token left in the router is not received", () => {
"Steps",
"Deadline",
]);
if (spent) {
expect(detail(result, "Moved from the sender").value).toBe(spent);
}
if (unread) {
expect(detail(result, "Moved from the sender").value).toBe(
UNDECODED,
);
}
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Token moved into the router")).toEqual(left);
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
@@ -2229,6 +2255,10 @@ describe("the allow-revert flag", () => {
});
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
// A permit to the router caps what the router takes of its token after
// it, unless the router lets it fail: then what the swap takes gets its
// own lines (https://git.eeqj.de/sneak/AutistMask/issues/625). The swap
// otherwise reads as without the flag.
test.each([
{ router: "UniversalRouterV1", address: ROUTER_V1, mark: "" },
{
@@ -2240,17 +2270,19 @@ describe("the allow-revert flag", () => {
router: "UniversalRouterV2",
address: ROUTER,
mark: " (allowed to fail)",
taken: "2000.0000 USDT",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2,
mark: " (allowed to fail)",
taken: "2000.0000 USDT",
},
])(
"on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap reads as without the flag",
({ address, mark }) => {
"on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark'",
({ address, mark, taken }) => {
const steps = [
permit2Permit(USDT, USDT_2000),
permit2Permit(USDT, USDT_2000, address),
swapToWeth(MSG_SENDER),
];
const plain = decode(execute(...steps), USER, address);
@@ -2262,9 +2294,20 @@ describe("the allow-revert flag", () => {
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));
const takenLines = [
"Moved from the sender",
"Token moved",
"Moved to",
];
const rest = (r) =>
r.details.filter(
(d) => d.label !== "Steps" && !takenLines.includes(d.label),
);
expect(rest(withFlag)).toEqual(rest(plain));
expect(detail(plain, "Moved from the sender")).toBeUndefined();
expect(detail(withFlag, "Moved from the sender")?.value).toBe(
taken,
);
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
},
);