fix: a permit after a swap step shows on its own lines instead of replacing Token In (closes #370)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s

A PERMIT2_PERMIT read after a step had set the input side named Token
In, and its amount could stand as the Amount figure, so the approval
screen could show an allowance where the swap's own input belonged.

Such a permit now leaves Token In and Amount to the swap and gets three
lines of its own: Permit2 allowance, Allowance token and Allowance
spender. A permit before the swap steps works as before, and a later
permit still keeps its amount from standing. README Transaction
Decoding and the TxApproval element list match.

Model: opus-5-5
This commit is contained in:
2026-10-20 19:51:49 +00:00
parent a3da448f0d
commit e185b96a32
3 changed files with 218 additions and 72 deletions
+105 -4
View File
@@ -1,4 +1,4 @@
const { AbiCoder, Interface, solidityPacked } = require("ethers");
const { AbiCoder, Interface, getAddress, solidityPacked } = require("ethers");
const uniswap = require("../src/shared/uniswap");
// A Universal Router on Ethereum Mainnet, chain id 0x1: UniversalRouterV2,
@@ -2168,8 +2168,8 @@ describe("uniswap decoder", () => {
];
// What a V3_SWAP_EXACT_OUT takes, at most its amountInMax, counts
// as a V2_SWAP_EXACT_OUT's does, and the Amount line is a maximum.
// The permit of 10,000 USDT stands; the one of 600 USDT does not,
// because the step before it took its token.
// The permit of 10,000 USDT stands. The one of 600 USDT comes after
// the step that set the input side, so it gets lines of its own.
test.each([
{
kind: "a V3 exact-in of 600 USDT, then a V3_SWAP_EXACT_OUT of at most 400 USDT",
@@ -2201,17 +2201,118 @@ describe("uniswap decoder", () => {
V3_600,
],
amount: "Up to 10000.0000 USDT",
permitLines: [
["Permit2 allowance", "600.0000 USDT"],
["Allowance token", "USDT (" + USDT_ADDR + ")"],
["Allowance spender", getAddress(ROUTER_ADDR)],
],
},
])(
"$kind, all paid by the user, shows the total as a maximum",
({ commands, inputs, amount }) => {
({ commands, inputs, amount, permitLines = [] }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([
["Amount", amount],
...permitLines,
]);
},
);
// https://git.eeqj.de/sneak/AutistMask/issues/370 — a PERMIT2_PERMIT
// after the step that set the input side named Token In in place of
// that step's token, and its amount could stand as the Amount figure.
// The swap's own token and amount now stay on those lines, and the
// permit gets lines of its own.
describe("a PERMIT2_PERMIT after the step that sets the input side", () => {
const DAI_5000 = 5000000000000000000000n;
const MAX_UINT160 = 2n ** 160n - 1n;
// The permit's lines, for `amount` of `token` and `spender`.
const permitLines = (amount, token, symbol, spender) => [
["Permit2 allowance", amount],
["Allowance token", symbol + " (" + token + ")"],
["Allowance spender", getAddress(spender)],
];
test.each([
{
kind: "of 5,000 DAI to the router",
permit: encodePermit2(DAI_ADDR, DAI_5000, ROUTER_ADDR),
lines: permitLines(
"5000.0000 DAI",
DAI_ADDR,
"DAI",
ROUTER_ADDR,
),
},
{
kind: "of unlimited DAI to another spender",
permit: encodePermit2(DAI_ADDR, MAX_UINT160, OTHER_ADDR),
lines: permitLines(
"Unlimited",
DAI_ADDR,
"DAI",
OTHER_ADDR,
),
},
{
kind: "of 600 USDT, the swap's own token, to the router",
permit: encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
lines: permitLines(
"600.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
},
])(
"a V3 exact-in of 600 USDT, then a PERMIT2_PERMIT $kind, shows the swap's USDT and the permit's own lines",
({ permit, lines }) => {
const result = decodeSteps([0x00, 0x0a], [V3_600, permit]);
expect(result.name).toBe("Swap USDT → USDC");
expect(
detail(result, "Token In").address.toLowerCase(),
).toBe(USDT_ADDR.toLowerCase());
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...lines,
]);
expect(detail(result, "Amount").rawValue).toBe("600.0000");
},
);
// The permit before the swap steps still names Token In. With a
// second permit its amount does not stand, so the Amount line
// gives the total the steps take of USDT, and the 400 DAI the
// last step takes gets lines of its own.
test("a PERMIT2_PERMIT of USDT, a V3 exact-in of 600 USDT, a PERMIT2_PERMIT of DAI and a V2 exact-in of 400 DAI shows both tokens' amounts and the second permit's own lines", () => {
const result = decodeSteps(
[0x0a, 0x00, 0x0a, 0x08],
[
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
V3_600,
encodePermit2(DAI_ADDR, DAI_400, ROUTER_ADDR),
encodeV2SwapExactIn(USER_ADDR, DAI_400, 390000000n, [
DAI_ADDR,
USDC_ADDR,
]),
],
);
expect(result.name).toBe("Swap USDT → USDC");
expect(linesBetweenTokens(result)).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"],
...permitLines(
"400.0000 DAI",
DAI_ADDR,
"DAI",
ROUTER_ADDR,
),
]);
});
});
// A V3_SWAP_EXACT_OUT whose path is shorter than one token cannot be
// read. It may pay any address and take from the sender, so it
// leaves no Amount figure.