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

The swap's Amount line took only the first swap step's amount. Each later
V2, V3 or V4 swap step the user pays for now adds what it spends of
Token In to that line, and one that spends another token gets Moved from
the sender, Token moved and Moved to lines of its own. Steps paid from
the router add nothing, and a PERMIT2_PERMIT keeps its own amount.

Model: opus-5-5
This commit is contained in:
2026-10-19 22:53:31 +00:00
committed by sneak
parent 5376723c41
commit 8382fab3b6
4 changed files with 365 additions and 64 deletions
+188
View File
@@ -1289,6 +1289,194 @@ describe("uniswap decoder", () => {
expect(detail(result, "Amount").value).not.toContain("500000000000");
});
// https://git.eeqj.de/sneak/AutistMask/issues/625 — a route split across
// two swap steps the user pays for, with no PERMIT2_PERMIT, takes both
// steps' amounts from the wallet. On next at 671b4d3 the Amount line read
// only the first step's 600.0000 USDT.
describe("a swap split across steps the user pays for", () => {
const USDT_600 = 600000000n;
const USDT_400 = 400000000n;
// The lines from Amount on, as [label, value].
function linesFromAmount(result, count) {
const lines = result.details.map((d) => [d.label, d.value]);
const at = lines.findIndex(([label]) => label === "Amount");
return lines.slice(at, at + count);
}
test("a split across a V3 and a V2 step the user pays for shows the total", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x08]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Token In").address.toLowerCase()).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Amount").rawValue).toBe("1000.0000");
expect(detail(result, "Moved from the sender")).toBeUndefined();
});
test("a split across a V3 step and a V4 step the user pays for shows the total", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV4Swap(
new Uint8Array([
V4_SETTLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ID,
]),
[
encodeV4Settle(USDT_ADDR), // paid by the user
encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
USDT_400,
390000000n,
),
encodeV4Take(USDC_ADDR),
],
),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Moved from the sender")).toBeUndefined();
});
test("a second step the user pays for that spends another token shows that token's amount", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x08]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactIn(
USER_ADDR,
400000000000000000000n, // 400 DAI
390000000n,
[DAI_ADDR, USDC_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(linesFromAmount(result, 4)).toEqual([
["Amount", "600.0000 USDT"],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"],
]);
});
// A PERMIT2_PERMIT that sets the input side keeps its own amount: the
// steps it lets the router spend do not add to it.
test("a split after a PERMIT2_PERMIT shows the permit's amount", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x00, 0x08]),
[
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR), // 5000 USDT
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Amount").value).toBe("5000.0000 USDT");
expect(detail(result, "Moved from the sender")).toBeUndefined();
});
// A V2 exact-out step makes every amount the swap steps spend a maximum,
// the total included.
test("a split with a V2 exact-out step the user pays for shows the total as a maximum", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x09]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactOut(USER_ADDR, 390000000n, USDT_400, [
USDT_ADDR,
USDC_ADDR,
]),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Amount").value).toBe("Up to 1000.0000 USDT");
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in
// the shape the issue measured: a V4 step that states a minimum of zero
// and names no output currency.