fix: a PERMIT2_PERMIT_BATCH, and a permit that cannot be read, state what they allow (closes #652)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

A PERMIT2_PERMIT_BATCH was never read, and a PERMIT2_PERMIT whose input
could not be read got no lines, so the approval screen showed nothing of
the allowances either grants.

Each allowance of a batch now gets the three lines a permit whose amount
does not stand gets: Permit2 allowance, Allowance token and Allowance
spender. A permit or batch that cannot be read gets one Permit2 allowance
line reading Unknown (could not be decoded) and leaves no Amount line.
Any batch in the transaction keeps a PERMIT2_PERMIT's amount from
standing. A batch pays nobody, so it no longer adds a Paid to line.
README Transaction Decoding and the TxApproval element list match.

Model: opus-5-5
This commit was merged in pull request #656.
This commit is contained in:
2026-10-21 01:41:35 +02:00
parent 45aead6340
commit f53b503b15
3 changed files with 292 additions and 94 deletions
+146 -17
View File
@@ -59,6 +59,25 @@ function encodePermit2(token, amount, spender) {
);
}
// Helper: encode a PERMIT2_PERMIT_BATCH input (command 0x03), one allowance
// for each [token, amount], all for `spender`
function encodePermit2Batch(allowances, spender) {
return coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48)[],address,uint256)",
"bytes",
],
[
[
allowances.map(([token, amount]) => [token, amount, 0, 0]),
spender,
9999999999,
],
"0x1234",
],
);
}
// Helper: encode a BALANCE_CHECK_ERC20 input (command 0x0e)
function encodeBalanceCheck(owner, token, minBalance) {
return coder.encode(
@@ -2100,28 +2119,17 @@ describe("uniswap decoder", () => {
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.
// A PERMIT2_PERMIT_BATCH may raise what the router may take of the
// permit's token again, and its allowance gets lines of its own.
test("a split after a PERMIT2_PERMIT and a PERMIT2_PERMIT_BATCH shows the total the steps take", () => {
const permitBatch = coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48)[],address,uint256)",
"bytes",
],
[
[
[[USDT_ADDR, 10000000000n, 0, 0]], // 10,000 USDT
ROUTER_ADDR,
9999999999,
],
"0x1234",
],
);
const result = decodeSteps(
[0x0a, 0x03, 0x00, 0x08],
[
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
permitBatch,
encodePermit2Batch(
[[USDT_ADDR, 10000000000n]], // 10,000 USDT
ROUTER_ADDR,
),
V3_600,
encodeV2SwapExactIn(USER_ADDR, 9400000000n, 1n, [
USDT_ADDR,
@@ -2132,6 +2140,12 @@ describe("uniswap decoder", () => {
expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"],
...USDT_600_LINES,
...permitLines(
"10000.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
]);
});
@@ -2418,6 +2432,121 @@ describe("uniswap decoder", () => {
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/652 — a
// PERMIT2_PERMIT_BATCH, and a PERMIT2_PERMIT this decoder cannot read,
// showed nothing of what they allow. Each allowance a batch grants now
// gets the lines of a permit whose amount does not stand, and a permit
// or batch that cannot be read gets one line saying so and leaves no
// Amount figure.
describe("a PERMIT2_PERMIT_BATCH, or a permit that cannot be read", () => {
const USDT_5000 = 5000000000n;
const DAI_5000 = 5000000000000000000000n;
const MAX_UINT160 = 2n ** 160n - 1n;
// A batch names one spender for all its allowances.
test.each([
{ kind: "the router", spender: ROUTER_ADDR },
{ kind: "another spender", spender: OTHER_ADDR },
])(
"a PERMIT2_PERMIT_BATCH of 5,000 USDT and unlimited DAI to $kind, then a V3 exact-in of 600 USDT, shows each allowance",
({ spender }) => {
const result = decodeSteps(
[0x03, 0x00],
[
encodePermit2Batch(
[
[USDT_ADDR, USDT_5000],
[DAI_ADDR, MAX_UINT160],
],
spender,
),
V3_600,
],
);
expect(result.name).toBe("Swap USDT → USDC");
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
spender,
),
...permitLines("Unlimited", DAI_ADDR, "DAI", spender),
]);
// A batch pays nobody, so the swap's minimum is what the
// sender receives.
expect(detail(result, "Paid to")).toBeUndefined();
expect(detail(result, "Min. received").value).toBe(
"590.0000 USDC",
);
},
);
// A batch keeps a PERMIT2_PERMIT's amount from standing as the
// Amount figure, before the permit as after it.
test("a PERMIT2_PERMIT_BATCH of 5,000 DAI, then a PERMIT2_PERMIT of 5,000 USDT to the router and a V3 exact-in of 600 USDT, shows the swap's 600 USDT and both permits' lines", () => {
const result = decodeSteps(
[0x03, 0x0a, 0x00],
[
encodePermit2Batch([[DAI_ADDR, DAI_5000]], ROUTER_ADDR),
encodePermit2(USDT_ADDR, USDT_5000, ROUTER_ADDR),
V3_600,
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...permitLines(
"5000.0000 DAI",
DAI_ADDR,
"DAI",
ROUTER_ADDR,
),
...permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
]);
});
// The permit's token word, and the batch's spender word, have a
// nonzero top byte.
test.each([
{
kind: "a PERMIT2_PERMIT",
command: 0x0a,
input: dirtyWord(
encodePermit2(USDT_ADDR, USDT_5000, ROUTER_ADDR),
0,
),
},
{
kind: "a PERMIT2_PERMIT_BATCH",
command: 0x03,
input: dirtyWord(
encodePermit2Batch(
[[USDT_ADDR, USDT_5000]],
ROUTER_ADDR,
),
3,
),
},
])(
"$kind that cannot be read, then a V3 exact-in of 600 USDT, says its allowances could not be decoded and shows no Amount figure",
({ command, input }) => {
const result = decodeSteps(
[command, 0x00],
[input, V3_600],
);
expect(linesBetweenTokens(result)).toEqual([
["Permit2 allowance", UNDECODED],
]);
},
);
});
// 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.