1 Commits
Author SHA1 Message Date
sneak e185b96a32 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
2026-10-20 19:51:49 +00:00
4 changed files with 74 additions and 184 deletions
+14 -19
View File
@@ -2423,7 +2423,7 @@ view would leave a wallet one click from deletion.
- Decoded action (if calldata is recognized): action name, token details,
amounts, what each swap step spends that the `Amount` line does not give,
what each `PERMIT2_TRANSFER_FROM` moves and where to, what each
`PERMIT2_PERMIT` whose amount is not the `Amount` figure lets its spender
`PERMIT2_PERMIT` after the step that sets the input side lets its spender
take, steps, deadline, and unless a swap pays only the From address, a
`Paid to` line for each other address it pays, or saying that a token
stays in the router or who it pays is unknown (see Transaction Decoding)
@@ -3273,24 +3273,19 @@ Currently supported:
step that sets the input side names neither `Token In` nor the `Amount`
figure, which keep that step's token and the total: a V3 exact-in of 600 USDT
followed by a permit of 5,000 DAI reads `Swap USDT → USDC` with
`Amount 600.0000 USDT`. Every `PERMIT2_PERMIT` whose amount does not stand as
the `Amount` figure, before the step that sets the input side or after it,
gives three lines, in step order, after those for each
`PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example
`5000.0000 DAI`, or `Unlimited` for Permit2's maximum; `Allowance token`,
giving the token's address as the `Token In` line does; and
`Allowance spender`, the address it lets take the token. So does one whose
spender is not the router, or that the router may let fail. Only a permit
whose amount stands gives none: the `Amount` line gives its amount, `Token In`
its token, and its spender is the router. These lines change neither the
`Paid to` lines nor the minimum's label. A step goes unread when its recipient
does not decode as an address, when it is a V3 swap step whose path is shorter
than one token address or has bytes after its last hop, or when it is a
command the decoder does not read: every command other than `PERMIT2_PERMIT`,
`PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
`V3_SWAP_EXACT_IN`, `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`,
`PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`,
`WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20`
`Amount 600.0000 USDT`. Each such permit, in step order, gives three lines
after those for each `PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount,
for example `5000.0000 DAI`, or `Unlimited` for Permit2's maximum;
`Allowance token`, giving the token's address as the `Token In` line does; and
`Allowance spender`, the address it lets take the token. These lines change
neither the `Paid to` lines nor the minimum's label. A step goes unread when
its recipient does not decode as an address, when it is a V3 swap step whose
path is shorter than one token address or has bytes after its last hop, or
when it is a command the decoder does not read: every command other than
`PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`,
`V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V3_SWAP_EXACT_OUT`, `V4_SWAP`,
`SWEEP`, `TRANSFER`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`,
`ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20`
(`PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and the NFT commands among
them), or a type its release names no command for.
+20 -27
View File
@@ -1212,11 +1212,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// The last PERMIT2_PERMIT this decoder read before a step set the
// input side, or null (see permitStands after the loop).
let permit = null;
// Every PERMIT2_PERMIT this decoder read, before a step set the input
// side or after, in step order, as { token, amount, spender, step }.
// Each whose amount is not the Amount figure gets lines of its own
// (see after the PERMIT2_TRANSFER_FROM lines).
const permits = [];
// Each PERMIT2_PERMIT this decoder read after a step set the input
// side, in step order (see the PERMIT2_PERMIT branch in the loop).
const permitsAfterInput = [];
// The index of each step this decoder did not read in full: a
// command it does not read, a step whose input it cannot read, in
// whole or in part, and one it fails on (see permitStands).
@@ -1471,16 +1469,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
spentUnread();
} else if (command === "PERMIT2_PERMIT") {
const p = decodePermit2(inputs[i]);
if (p) {
permits.push({ ...p, step: i });
if (p && inputEstablished) {
// After a step set the input side, a permit names
// neither Token In nor the Amount figure: the swap's
// own input token and amount stay on those lines, and
// the permit's token, amount and spender get lines
// of their own.
permitsAfterInput.push(p);
keepsRouterWeth = true;
} else {
stepsUnread.push(i);
}
// After a step set the input side, a permit names
// neither Token In nor the Amount figure: the swap's own
// input token and amount stay on those lines.
if (p && !inputEstablished) {
} else if (p) {
// Whether a step before it took its token, or may
// have: one in `userPaid`, when its token is the
// permit's or not known. No step has set the input
@@ -1496,6 +1493,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
allowedToFail,
beforeEveryTake: !tookBefore,
};
keepsRouterWeth = true;
} else {
stepsUnread.push(i);
}
} else if (command === "PERMIT2_TRANSFER_FROM") {
const p = decodePermit2TransferFrom(inputs[i]);
@@ -1965,9 +1965,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// take from the sender what this decoder did not read leaves no
// figure, permit or not (see spentUnread()), and an EXECUTE_SUB_PLAN
// step leaves both sides unknown (below). A PERMIT2_PERMIT after a
// step set the input side counts among the PERMIT2_PERMITs here.
// Every PERMIT2_PERMIT but one whose amount stands gets lines of its
// own below.
// step set the input side counts among the PERMIT2_PERMITs here, and
// gets lines of its own below.
const stepCommands = Array.from(
commandsBytes,
(b) => commands[b & commandTypeMask],
@@ -2198,16 +2197,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
);
}
// What each PERMIT2_PERMIT lets its spender take: its amount, its
// token and the spender, an address on a line of its own. Permit2's
// maximum is unbounded. Only a permit whose amount stands as the
// Amount figure (see permitStands) gets none: that line already gives
// its amount and Token In its token, and its spender is the router.
// Every other permit, one after the step that set the input side
// included, gets these lines, so nothing it lets anyone take goes
// unstated.
for (const p of permits) {
if (permitStands && p.step === permit.step) continue;
// What each PERMIT2_PERMIT after the step that set the input side
// lets its spender take: its amount, its token and the spender, an
// address on a line of its own. Permit2's maximum is unbounded.
for (const p of permitsAfterInput) {
details.push({
label: "Permit2 allowance",
value:
+36 -119
View File
@@ -1406,14 +1406,6 @@ 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) {
@@ -1719,7 +1711,6 @@ 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,",
@@ -1728,12 +1719,6 @@ 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.
{
@@ -1750,18 +1735,11 @@ describe("uniswap decoder", () => {
1,
),
],
permit: permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
},
])("$kind shows the 5,000 USDT", ({ commands, inputs, permit }) => {
])("$kind shows the 5,000 USDT", ({ commands, inputs }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "5000.0000 USDT"],
...permit,
]);
});
@@ -2045,24 +2023,19 @@ 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,
// and each permit gets lines of its own.
// on, so the line gives the total the steps take, as with no permit.
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",
@@ -2071,14 +2044,10 @@ 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, lines }) => {
({ commands, permits }) => {
const result = decodeSteps(
[...commands, 0x00, 0x08],
[
@@ -2092,20 +2061,10 @@ 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", () => {
@@ -2137,7 +2096,6 @@ describe("uniswap decoder", () => {
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"],
...USDT_600_LINES,
]);
});
@@ -2183,7 +2141,6 @@ describe("uniswap decoder", () => {
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"],
...USDT_600_LINES,
]);
},
);
@@ -2212,8 +2169,7 @@ 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 comes after
// the step that set the input side, so it does not stand and gets
// lines of its own.
// 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",
@@ -2245,15 +2201,19 @@ describe("uniswap decoder", () => {
V3_600,
],
amount: "Up to 10000.0000 USDT",
permit: USDT_600_LINES,
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, permit = [] }) => {
({ commands, inputs, amount, permitLines = [] }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([
["Amount", amount],
...permit,
...permitLines,
]);
},
);
@@ -2261,23 +2221,22 @@ describe("uniswap decoder", () => {
// 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. That
// permit, and every other whose amount does not stand as the Amount
// figure, gets lines of its own; a permit before the swap steps still
// names Token In.
describe("a PERMIT2_PERMIT whose amount does not 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 USDT_5000 = 5000000000n;
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: "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),
],
kind: "of 5,000 DAI to the router",
permit: encodePermit2(DAI_ADDR, DAI_5000, ROUTER_ADDR),
lines: permitLines(
"5000.0000 DAI",
DAI_ADDR,
@@ -2286,12 +2245,8 @@ describe("uniswap decoder", () => {
),
},
{
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),
],
kind: "of unlimited DAI to another spender",
permit: encodePermit2(DAI_ADDR, MAX_UINT160, OTHER_ADDR),
lines: permitLines(
"Unlimited",
DAI_ADDR,
@@ -2300,46 +2255,19 @@ describe("uniswap decoder", () => {
),
},
{
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,
],
kind: "of 600 USDT, the swap's own token, to the router",
permit: encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
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",
"600.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
},
])(
"$kind shows the swap's 600 USDT and the permit's own lines",
({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
"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(),
@@ -2354,13 +2282,13 @@ describe("uniswap decoder", () => {
// 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, 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", () => {
// 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, USDT_5000, ROUTER_ADDR),
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
V3_600,
encodePermit2(DAI_ADDR, DAI_400, ROUTER_ADDR),
encodeV2SwapExactIn(USER_ADDR, DAI_400, 390000000n, [
@@ -2375,12 +2303,6 @@ describe("uniswap decoder", () => {
["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,
@@ -2436,7 +2358,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, so the permit gets lines of its own.
// it also takes WETH.
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(
@@ -2452,7 +2374,6 @@ 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);
});
@@ -2623,11 +2544,7 @@ describe("uniswap decoder", () => {
V4_EXACT_OUT_600,
V2_EXACT_OUT_400,
],
lines: [
...UNKNOWN_USDT,
...UP_TO_400_USDT,
...ONE_USDT_LINES(OTHER_ADDR),
],
lines: [...UNKNOWN_USDT, ...UP_TO_400_USDT],
},
{
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",
+4 -19
View File
@@ -2809,7 +2809,7 @@ describe("the allow-revert flag", () => {
mark: " (allowed to fail)",
},
])(
"on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap's lines read as without the flag",
"on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap reads as without the flag",
({ address, mark }) => {
const steps = [
permit2Permit(USDT, USDT_2000),
@@ -2824,24 +2824,9 @@ describe("the allow-revert flag", () => {
expect(detail(withFlag, "Steps").value).toBe(
"Permit2 Permit" + mark + " → V3 Swap (Exact In)",
);
// The permit names the router at ROUTER as its spender, so its
// amount stands as the Amount figure only there, and only without
// the flag. With the flag it stands on no router and gets lines
// of its own.
const permitLines = [
"Permit2 allowance",
"Allowance token",
"Allowance spender",
];
const swapLines = (r) =>
r.details.filter(
(d) =>
d.label !== "Steps" && !permitLines.includes(d.label),
);
expect(swapLines(withFlag)).toEqual(swapLines(plain));
expect(
labels(withFlag).filter((l) => permitLines.includes(l)),
).toEqual(permitLines);
const withoutSteps = (r) =>
r.details.filter((d) => d.label !== "Steps");
expect(withoutSteps(withFlag)).toEqual(withoutSteps(plain));
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
},
);