fix: only a permit whose amount is the Amount figure names Token In (closes #370)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

A PERMIT2_PERMIT named Token In whether or not its amount stood as the
Amount figure, so the approval screen could show a permit's token, or an
allowance, where the swap's own input belonged.

Now only a permit whose amount stands names Token In. Every other
permit, before the swap steps or after them, leaves Token In and Amount
as they read with no permit and gets three lines of its own: Permit2
allowance, Allowance token and Allowance spender. A V3 swap step's
tokens are checksummed, as every other decoded address is, so a token's
address reads the same on every line. README Transaction Decoding, its
unknown token scale passage and the TxApproval element list match.

Model: opus-5-5
This commit is contained in:
2026-10-20 22:19:52 +00:00
parent e517219038
commit b57bf071a7
4 changed files with 396 additions and 115 deletions
+231 -24
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,
@@ -696,12 +696,8 @@ describe("uniswap decoder", () => {
USER_ADDR,
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Token In").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Token Out").address).toBe(
WETH_ADDR.toLowerCase(),
);
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(WETH_ADDR);
const amount = detail(result, "Amount");
expect(amount.value).toBe("Up to 2000.0000 USDT");
@@ -735,9 +731,7 @@ describe("uniswap decoder", () => {
expect(result.name).toBe("Swap ETH → USDC");
expect(detail(result, "Amount").value).toBe("Up to 0.5000 ETH");
expect(detail(result, "Moved from the sender")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe(
USDC_ADDR.toLowerCase(),
);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Paid to")).toBeUndefined();
expect(detail(result, "Min. received").value).toBe("1500.0000 USDC");
});
@@ -1406,6 +1400,14 @@ describe("uniswap decoder", () => {
return lines.slice(from + 1, to);
}
// The lines of a PERMIT2_PERMIT whose amount does not stand as the
// Amount figure, of `amount` of `token` for `spender`.
const permitLines = (amount, token, symbol, spender) => [
["Permit2 allowance", amount],
["Allowance token", symbol + " (" + token + ")"],
["Allowance spender", getAddress(spender)],
];
// execute() of these command bytes and inputs, decoded as sent by
// the user.
function decodeSteps(commands, inputs) {
@@ -1711,6 +1713,7 @@ describe("uniswap decoder", () => {
kind: "a V4 step swapping 400 USDT with a SETTLE of 5,000 USDT by the user, the rest taken to another address,",
commands: [0x10],
inputs: [V4_SETTLE_5000],
permit: [],
},
{
kind: "a PERMIT2_PERMIT of 1 USDT that the router may let fail, then that V4 step,",
@@ -1719,6 +1722,12 @@ describe("uniswap decoder", () => {
encodePermit2(USDT_ADDR, 1000000n, ROUTER_ADDR),
V4_SETTLE_5000,
],
permit: permitLines(
"1.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
},
// The unread SWEEP keeps the permit's amount from standing.
{
@@ -1735,11 +1744,18 @@ describe("uniswap decoder", () => {
1,
),
],
permit: permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
},
])("$kind shows the 5,000 USDT", ({ commands, inputs }) => {
])("$kind shows the 5,000 USDT", ({ commands, inputs, permit }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "5000.0000 USDT"],
...permit,
]);
});
@@ -2023,19 +2039,24 @@ describe("uniswap decoder", () => {
});
// Out of that shape the permit caps nothing the Amount line can rely
// on, so the line gives the total the steps take, as with no permit.
// on, so the line gives the total the steps take, as with no permit,
// and each permit gets lines of its own.
const ONE_USDT_PERMIT = (spender) =>
encodePermit2(USDT_ADDR, 1000000n, spender);
const ONE_USDT_LINES = (spender) =>
permitLines("1.0000 USDT", USDT_ADDR, "USDT", spender);
test.each([
{
kind: "to another spender",
commands: [0x0a],
permits: [ONE_USDT_PERMIT(OTHER_ADDR)],
lines: ONE_USDT_LINES(OTHER_ADDR),
},
{
kind: "that the router lets fail",
commands: [0x8a],
permits: [ONE_USDT_PERMIT(ROUTER_ADDR)],
lines: ONE_USDT_LINES(ROUTER_ADDR),
},
{
kind: "followed by a second one",
@@ -2044,10 +2065,14 @@ describe("uniswap decoder", () => {
ONE_USDT_PERMIT(ROUTER_ADDR),
ONE_USDT_PERMIT(ROUTER_ADDR),
],
lines: [
...ONE_USDT_LINES(ROUTER_ADDR),
...ONE_USDT_LINES(ROUTER_ADDR),
],
},
])(
"a split after a PERMIT2_PERMIT $kind shows the total the steps take",
({ commands, permits }) => {
({ commands, permits, lines }) => {
const result = decodeSteps(
[...commands, 0x00, 0x08],
[
@@ -2061,10 +2086,20 @@ describe("uniswap decoder", () => {
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "1000.0000 USDT"],
...lines,
]);
},
);
// The lines of a PERMIT2_PERMIT of 600 USDT to the router whose
// amount does not stand.
const USDT_600_LINES = permitLines(
"600.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
);
// A PERMIT2_PERMIT_BATCH, which this decoder does not read, may raise
// what the router may take of the permit's token again.
test("a split after a PERMIT2_PERMIT and a PERMIT2_PERMIT_BATCH shows the total the steps take", () => {
@@ -2096,6 +2131,7 @@ describe("uniswap decoder", () => {
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"],
...USDT_600_LINES,
]);
});
@@ -2141,6 +2177,7 @@ describe("uniswap decoder", () => {
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"],
...USDT_600_LINES,
]);
},
);
@@ -2168,8 +2205,9 @@ 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 does not stand and 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 +2239,185 @@ describe("uniswap decoder", () => {
V3_600,
],
amount: "Up to 10000.0000 USDT",
permit: USDT_600_LINES,
},
])(
"$kind, all paid by the user, shows the total as a maximum",
({ commands, inputs, amount }) => {
({ commands, inputs, amount, permit = [] }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([
["Amount", amount],
...permit,
]);
},
);
// 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.
// Only a permit whose amount stands as the Amount figure names Token
// In now. Every other, before the swap steps or after them, leaves
// Token In and Amount as they read with no permit and gets lines of
// its own.
describe("a PERMIT2_PERMIT whose amount does not stand as the Amount figure", () => {
const DAI_5000 = 5000000000000000000000n;
const USDT_5000 = 5000000000n;
const MAX_UINT160 = 2n ** 160n - 1n;
test.each([
{
kind: "a V3 exact-in of 600 USDT, then a PERMIT2_PERMIT of 5,000 DAI to the router,",
commands: [0x00, 0x0a],
inputs: [
V3_600,
encodePermit2(DAI_ADDR, DAI_5000, ROUTER_ADDR),
],
lines: permitLines(
"5000.0000 DAI",
DAI_ADDR,
"DAI",
ROUTER_ADDR,
),
},
{
kind: "a V3 exact-in of 600 USDT, then a PERMIT2_PERMIT of unlimited DAI to another spender,",
commands: [0x00, 0x0a],
inputs: [
V3_600,
encodePermit2(DAI_ADDR, MAX_UINT160, OTHER_ADDR),
],
lines: permitLines(
"Unlimited",
DAI_ADDR,
"DAI",
OTHER_ADDR,
),
},
{
kind: "a V3 exact-in of 600 USDT, then a PERMIT2_PERMIT of 600 USDT, the swap's own token, to the router,",
commands: [0x00, 0x0a],
inputs: [
V3_600,
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
],
lines: USDT_600_LINES,
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to another spender, then a V3 exact-in of 600 USDT,",
commands: [0x0a, 0x00],
inputs: [
encodePermit2(USDT_ADDR, USDT_5000, OTHER_ADDR),
V3_600,
],
lines: permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
OTHER_ADDR,
),
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router that the router may let fail, then a V3 exact-in of 600 USDT,",
commands: [0x8a, 0x00],
inputs: [
encodePermit2(USDT_ADDR, USDT_5000, ROUTER_ADDR),
V3_600,
],
lines: permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
},
{
kind: "a PERMIT2_PERMIT of 5,000 DAI to another spender, then a V3 exact-in of 600 USDT,",
commands: [0x0a, 0x00],
inputs: [
encodePermit2(DAI_ADDR, DAI_5000, OTHER_ADDR),
V3_600,
],
lines: permitLines(
"5000.0000 DAI",
DAI_ADDR,
"DAI",
OTHER_ADDR,
),
},
{
kind: "a PERMIT2_PERMIT of 5,000 DAI to the router, a V3 exact-in of 600 USDT, then a PERMIT2_PERMIT of 600 USDT to the router,",
commands: [0x0a, 0x00, 0x0a],
inputs: [
encodePermit2(DAI_ADDR, DAI_5000, ROUTER_ADDR),
V3_600,
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
],
lines: [
...permitLines(
"5000.0000 DAI",
DAI_ADDR,
"DAI",
ROUTER_ADDR,
),
...USDT_600_LINES,
],
},
])(
"$kind shows the swap's 600 USDT and each permit's own lines",
({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(result.name).toBe("Swap USDT → USDC");
// The swap step names Token In, its address checksummed,
// as on a permit's Allowance token line.
expect(detail(result, "Token In").value).toBe(
"USDT (" + USDT_ADDR + ")",
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...lines,
]);
expect(detail(result, "Amount").rawValue).toBe("600.0000");
},
);
// With two permits neither amount stands, so the Amount line
// gives the total the steps take of USDT, the 400 DAI the last
// step takes gets lines of its own, and so does each permit.
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 each permit's own lines", () => {
const result = decodeSteps(
[0x0a, 0x00, 0x0a, 0x08],
[
encodePermit2(USDT_ADDR, USDT_5000, 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(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
...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.
@@ -2257,7 +2463,7 @@ describe("uniswap decoder", () => {
// A V3 exact-out of 1,500 USDC for at most 0.5 WETH spends WETH, not
// SPK. After it, a PERMIT2_PERMIT of 1 WETH and a V3 exact-in of 0.5
// WETH: the permit's amount does not stand, because the step before
// it also takes WETH.
// it also takes WETH, so the permit gets lines of its own.
test("a V3_SWAP_EXACT_OUT whose path has bytes after its last hop, then a PERMIT2_PERMIT and a V3 exact-in, shows no Amount figure", () => {
const path = pathEndingIn([USDC_ADDR, WETH_ADDR], SPK_ADDR);
const result = decodeSteps(
@@ -2273,6 +2479,7 @@ describe("uniswap decoder", () => {
);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
...permitLines("1.0000 WETH", WETH_ADDR, "WETH", ROUTER_ADDR),
]);
expect(detail(result, "Paid to").value).toBe(UNDECODED);
});
@@ -2443,7 +2650,11 @@ describe("uniswap decoder", () => {
V4_EXACT_OUT_600,
V2_EXACT_OUT_400,
],
lines: [...UNKNOWN_USDT, ...UP_TO_400_USDT],
lines: [
...UNKNOWN_USDT,
...UP_TO_400_USDT,
...ONE_USDT_LINES(OTHER_ADDR),
],
},
{
kind: "a V4 step whose swap action cannot be read, with a SETTLE of WETH by the user, then a V2 exact-out of at most 400 USDT",
@@ -3069,9 +3280,7 @@ describe("uniswap decoder", () => {
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
@@ -3129,9 +3338,7 @@ describe("uniswap decoder", () => {
"Unknown (not named in the calldata)",
);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Token Out").address).toBe(USDT_ADDR);
expect(detail(result, "Min. received").value).toBe("990.0000 USDT");
});