fix: a permit on the zero address never reads as ETH (closes #660)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

Permit2 has no allowance of ETH, but the swap decoder read the zero address in a PERMIT2_PERMIT or a batch entry as ETH. Its Allowance token line now names the zero address, as for a token nothing knows. Its amount never stands as the Amount figure, and it never counts as an allowance of Token In, so a route that pays ETH after such a permit shows that ETH. Tests cover the single permit, the batch entry and the 1-wei route.

Model: opus-5-5
This commit was merged in pull request #683.
This commit is contained in:
2026-10-27 05:12:38 +01:00
parent b4e166cdc7
commit 36b200247a
3 changed files with 232 additions and 73 deletions
+56 -52
View File
@@ -3506,34 +3506,36 @@ Currently supported:
the router take `Token In` (see below): a permit of 5,000 DAI to another the router take `Token In` (see below): a permit of 5,000 DAI to another
address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` with address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` with
`Amount 600.0000 USDT`, and so do that exact-in and then a permit of 5,000 DAI `Amount 600.0000 USDT`, and so do that exact-in and then a permit of 5,000 DAI
to the router. When a permit's amount stands and the step that sets the input to the router. A permit on the zero address never stands as the `Amount`
side spends a token other than the permit's, or one the decoder does not know, figure, because Permit2 has no allowance of ETH. When a permit's amount stands
that step's amount gets lines of its own, unless it states no amount and the and the step that sets the input side spends a token other than the permit's,
user pays it none of its input token. Each take the total does not count gives or one the decoder does not know, that step's amount gets lines of its own,
three lines after the `Amount` line (after `Token In` when there is none): one unless it states no amount and the user pays it none of its input token. Each
of a token other than `Token In`; one of `Token In` after an amount that is take the total does not count gives three lines after the `Amount` line (after
not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their wording on `Token In` when there is none): one of a token other than `Token In`; one of
the `Amount` line, or no stated amount, as of a V4 exact-out step); and one `Token In` after an amount that is not a quantity (a V4 open delta,
whose amount the decoder does not know. When there is no `Amount` line, each `ALREADY_PAID`, which keep their wording on the `Amount` line, or no stated
take the total counts gets them too, so that every amount the decoder reads is amount, as of a V4 exact-out step); and one whose amount the decoder does not
shown: the amount the step that sets the input side states, when it is a know. When there is no `Amount` line, each take the total counts gets them
quantity and the user pays that step (a V2 or V3 step whose `payerIsUser` is too, so that every amount the decoder reads is shown: the amount the step that
true, a V4 step the user pays some of its input token, or a `WRAP_ETH`, whose sets the input side states, when it is a quantity and the user pays that step
ETH comes from the transaction's value), and each later take of `Token In` (a V2 or V3 step whose `payerIsUser` is true, a V4 step the user pays some of
added to it. A step that sets the input side and is paid from the router takes its input token, or a `WRAP_ETH`, whose ETH comes from the transaction's
nothing from the user's wallet and gets none. A `PERMIT2_PERMIT` the decoder value), and each later take of `Token In` added to it. A step that sets the
cannot read, then V3 exact-ins of 600 USDT and 400 DAI paid by the user, give input side and is paid from the router takes nothing from the user's wallet
lines for both amounts. When the step that sets the input side states no and gets none. A `PERMIT2_PERMIT` the decoder cannot read, then V3 exact-ins
amount, the user pays it some of its input token, and there is no `Amount` of 600 USDT and 400 DAI paid by the user, give lines for both amounts. When
line, that step gets them too whenever another take's lines give a figure, or the step that sets the input side states no amount, the user pays it some of
a `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines below or, into its input token, and there is no `Amount` line, that step gets them too
the router, on its `Paid to` line, so that figure cannot read as all that whenever another take's lines give a figure, or a `PERMIT2_TRANSFER_FROM` of
leaves. When it states no amount and the user pays it none of its input token, its token gives one, on the lines below or, into the router, on its `Paid to`
as when the router pays it, that step takes none of that token from the user's line, so that figure cannot read as all that leaves. When it states no amount
wallet and never gets them, with a permit or without. They come in step order, and the user pays it none of its input token, as when the router pays it, that
except that the step that sets the input side comes first when it has them, step takes none of that token from the user's wallet and never gets them, with
followed, when there is no `Amount` line, by the other takes the total counts: a permit or without. They come in step order, except that the step that sets
`Moved from the sender`, its amount, worded as the `Amount` line words one, or the input side comes first when it has them, followed, when there is no
`Amount` line, by the other takes the total counts: `Moved from the sender`,
its amount, worded as the `Amount` line words one, or
`Unknown (could not be decoded)` when the decoder does not know it; `Unknown (could not be decoded)` when the decoder does not know it;
`Token moved`, giving the token's address as the `Token In` line does; and `Token moved`, giving the token's address as the `Token In` line does; and
`Moved to`, which reads `The swap step that spends it`. A token the decoder `Moved to`, which reads `The swap step that spends it`. A token the decoder
@@ -3566,30 +3568,32 @@ Currently supported:
amount stands gives none: the `Amount` line gives its amount, `Token In` its amount stands gives none: the `Amount` line gives its amount, `Token In` its
token, and its spender is the router. Each allowance a `PERMIT2_PERMIT_BATCH` token, and its spender is the router. Each allowance a `PERMIT2_PERMIT_BATCH`
grants, one for each token it names, all for its one spender, gives the same grants, one for each token it names, all for its one spender, gives the same
three lines, in step order with those of each `PERMIT2_PERMIT`. A three lines, in step order with those of each `PERMIT2_PERMIT`. For an
`PERMIT2_PERMIT` or `PERMIT2_PERMIT_BATCH` whose input the decoder cannot allowance of the zero address, `Permit2 allowance` gives the amount in base
read, such as one whose token or spender word has a nonzero top byte, may let units and `Allowance token` gives the zero address. A `PERMIT2_PERMIT` or
any spender take any amount of any token: it gives one `Permit2 allowance` `PERMIT2_PERMIT_BATCH` whose input the decoder cannot read, such as one whose
line alone, reading `Unknown (could not be decoded)`. These lines change no token or spender word has a nonzero top byte, may let any spender take any
`Paid to` line: a permit or batch pays nobody. But code that a step runs, such amount of any token: it gives one `Permit2 allowance` line alone, reading
as a V4 pool's hook or a token's own code, can call a spender other than the `Unknown (could not be decoded)`. These lines change no `Paid to` line: a
router, which can then take up to its allowance from the user's wallet before permit or batch pays nobody. But code that a step runs, such as a V4 pool's
the transaction ends. So an allowance to any spender but the router, or a hook or a token's own code, can call a spender other than the router, which
permit or batch the decoder cannot read, makes the minimum read can then take up to its allowance from the user's wallet before the
`Min. paid out`, and when its token is `Token In`, or the decoder does not transaction ends. So an allowance to any spender but the router, or a permit
know its token or `Token In`, it leaves no `Amount` line, whose figure might or batch the decoder cannot read, makes the minimum read `Min. paid out`, and
be too low: a permit of 5,000 USDT to another address and then a V3 exact-in when its token is `Token In`, or the decoder does not know its token or
of 600 USDT read with no `Amount` line, and the 600 USDT gets lines of its `Token In`, it leaves no `Amount` line, whose figure might be too low: a
own. A step goes unread when its recipient does not decode as an address, when permit of 5,000 USDT to another address and then a V3 exact-in of 600 USDT
it is a V3 swap step whose path is shorter than one token address or has bytes read with no `Amount` line, and the 600 USDT gets lines of its own. A step
after its last hop, or when it is a command the decoder does not read: every goes unread when its recipient does not decode as an address, when it is a V3
command other than `PERMIT2_PERMIT`, `PERMIT2_PERMIT_BATCH`, swap step whose path is shorter than one token address or has bytes after its
`PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, last hop, or when it is a command the decoder does not read: every command
`V3_SWAP_EXACT_IN`, `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`, other than `PERMIT2_PERMIT`, `PERMIT2_PERMIT_BATCH`, `PERMIT2_TRANSFER_FROM`,
`PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`,
`WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20` `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`,
(`PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and the NFT commands among `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`,
them), or a type its release names no command for. `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.
### Non-Goals Forever ### Non-Goals Forever
+47 -21
View File
@@ -2032,12 +2032,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// it may take more. A later permit or batch, or a later step this // it may take more. A later permit or batch, or a later step this
// decoder did not read, may raise what Permit2 lets the router take. // decoder did not read, may raise what Permit2 lets the router take.
// Every other PERMIT2_PERMIT leaves Token In and the Amount line as // Every other PERMIT2_PERMIT leaves Token In and the Amount line as
// they read with no permit, and gets lines of its own below. A step // they read with no permit, and gets lines of its own below. A permit
// that may take from the sender what this decoder did not read leaves // on the zero address never stands as the Amount figure, because
// no figure, permit or not (see spentUnread()), and so do a permit // Permit2 has no allowance of ETH. A step that may take from the
// or batch whose input it cannot read (see permitUnread()) and an // sender what this decoder did not read leaves no figure, permit or
// allowance of Token In to another spender (see toAnotherSpender). An // not (see spentUnread()), and so do a permit or batch whose input it
// EXECUTE_SUB_PLAN step leaves both sides unknown (below). // cannot read (see permitUnread()) and an allowance of Token In to
// another spender (see toAnotherSpender). An EXECUTE_SUB_PLAN step
// leaves both sides unknown (below).
const stepCommands = Array.from( const stepCommands = Array.from(
commandsBytes, commandsBytes,
(b) => commands[b & commandTypeMask], (b) => commands[b & commandTypeMask],
@@ -2047,6 +2049,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 && stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 &&
!stepCommands.includes("PERMIT2_PERMIT_BATCH") && !stepCommands.includes("PERMIT2_PERMIT_BATCH") &&
sameAddress(permit.spender, toAddress) && sameAddress(permit.spender, toAddress) &&
permit.token !== "0x0000000000000000000000000000000000000000" &&
!permit.allowedToFail && !permit.allowedToFail &&
permit.beforeEveryTake && permit.beforeEveryTake &&
!stepsUnread.some((step) => step > permit.step); !stepsUnread.some((step) => step > permit.step);
@@ -2234,17 +2237,19 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// A null token in `spentFromSender`, a step this decoder did not read // A null token in `spentFromSender`, a step this decoder did not read
// or a token it does not know, and an allowance to another spender // or a token it does not know, and an allowance to another spender
// that may be of Token In, leave no Amount figure, which may then be // that may be of Token In, leave no Amount figure, which may then be
// too low. With no Amount line, each take the total adds up gets // too low. An allowance of the zero address is never one of Token In,
// lines of its own, first, so that every amount this decoder read is // because Permit2 has no allowance of ETH. With no Amount line, each
// shown. // take the total adds up gets lines of its own, first, so that every
// amount this decoder read is shown.
if ( if (
present(inputAmount) && present(inputAmount) &&
spentFromSender.every((s) => present(s.token)) && spentFromSender.every((s) => present(s.token)) &&
toAnotherSpender.every( toAnotherSpender.every(
(p) => (p) =>
present(p.token) && p.token === "0x0000000000000000000000000000000000000000" ||
present(inputToken) && (present(p.token) &&
!sameAddress(p.token, inputToken), present(inputToken) &&
!sameAddress(p.token, inputToken)),
) )
) { ) {
const amount = spentText(inputAmount, inInfo); const amount = spentText(inputAmount, inInfo);
@@ -2315,15 +2320,36 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}); });
continue; continue;
} }
details.push({ // Permit2 has no allowance of ETH. An allowance of the zero
label: "Permit2 allowance", // address reads as one of a token nothing knows: its amount in
value: // base units, and the zero address as its token.
p.amount >= MAX_UINT160 if (p.token === "0x0000000000000000000000000000000000000000") {
? "Unlimited" details.push({
: amountText(p.amount, tokenInfo(p.token, sources)) label: "Permit2 allowance",
.display, value:
}); p.amount >= MAX_UINT160
details.push(tokenMovedLine("Allowance token", p.token, sources)); ? "Unlimited"
: unknownDecimalsAmount(p.amount),
});
details.push({
label: "Allowance token",
value: p.token,
address: p.token,
isToken: true,
});
} else {
details.push({
label: "Permit2 allowance",
value:
p.amount >= MAX_UINT160
? "Unlimited"
: amountText(p.amount, tokenInfo(p.token, sources))
.display,
});
details.push(
tokenMovedLine("Allowance token", p.token, sources),
);
}
details.push({ details.push({
label: "Allowance spender", label: "Allowance spender",
value: p.spender, value: p.spender,
+129
View File
@@ -3310,6 +3310,135 @@ describe("uniswap decoder", () => {
expect(detail(result, "Amount").value).toBe("Up to 1000.0000 USDT"); expect(detail(result, "Amount").value).toBe("Up to 1000.0000 USDT");
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/660 — Permit2 has no
// allowance of ETH, so a permit on the zero address reads as one on a
// token nothing knows. On next at 53551c6 such a permit's Allowance
// token line read ETH. A permit of 1 wei of the zero address before a
// V4 step of 5 ETH made the Amount line read 0.000000000000000001 ETH.
// With that permit to another spender and a SETTLE the router pays, no
// line gave the 5 ETH.
describe("a permit on the zero address", () => {
const FIVE_ETH = 5000000000000000000n;
// The lines of a permit of `amount` base units of the zero
// address for `spender`.
const zeroAddressLines = (amount, spender) => [
[
"Permit2 allowance",
amount + " base units (decimals unknown)",
],
["Allowance token", ZERO_ADDR],
["Allowance spender", getAddress(spender)],
];
// A V4 swap of 5 ETH for USDC. The user pays its SETTLE when
// `payerIsUser` is true; otherwise the router pays it from the
// transaction's value.
const v4FiveEth = (payerIsUser) =>
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(
ZERO_ADDR,
USDC_ADDR,
FIVE_ETH,
1n,
),
settle(ZERO_ADDR, FIVE_ETH, payerIsUser),
encodeV4TakeAll(USDC_ADDR, 1n),
],
);
test("a PERMIT2_PERMIT on the zero address names the address", () => {
const result = decodeSteps(
[0x00, 0x0a],
[
V3_600,
encodePermit2(
ZERO_ADDR,
1000000000000000000n,
ROUTER_ADDR,
),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...zeroAddressLines("1000000000000000000", ROUTER_ADDR),
]);
expect(detail(result, "Allowance token").address).toBe(
ZERO_ADDR,
);
});
test("a PERMIT2_PERMIT_BATCH entry on the zero address names the address", () => {
const result = decodeSteps(
[0x03, 0x00],
[
encodePermit2Batch(
[
[USDT_ADDR, USDT_600],
[ZERO_ADDR, 1000000000000000000n],
],
ROUTER_ADDR,
),
V3_600,
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...USDT_600_LINES,
...zeroAddressLines("1000000000000000000", ROUTER_ADDR),
]);
});
test("a PERMIT2_PERMIT of 1 wei of the zero address, then a V4 step of 5 ETH the user settles, shows the 5 ETH", () => {
const result = decodeSteps(
[0x0a, 0x10],
[
encodePermit2(ZERO_ADDR, 1n, ROUTER_ADDR),
v4FiveEth(true),
],
);
expect(detail(result, "Token In").value).toBe("ETH (native)");
expect(linesBetweenTokens(result)).toEqual([
["Amount", "5.0000 ETH"],
...zeroAddressLines("1", ROUTER_ADDR),
]);
});
test("a PERMIT2_PERMIT of 1 wei of the zero address to another spender, then a V4 step of 5 ETH the router settles, shows the 5 ETH", () => {
const result = decodeSteps(
[0x0a, 0x10],
[
encodePermit2(ZERO_ADDR, 1n, OTHER_ADDR),
v4FiveEth(false),
],
);
expect(detail(result, "Token In").value).toBe("ETH (native)");
expect(linesBetweenTokens(result)).toEqual([
["Amount", "5.0000 ETH"],
...zeroAddressLines("1", OTHER_ADDR),
]);
});
test("a PERMIT2_PERMIT_BATCH entry of 1 wei of the zero address to another spender, then a V4 step of 5 ETH the router settles, shows the 5 ETH", () => {
const result = decodeSteps(
[0x03, 0x10],
[
encodePermit2Batch([[ZERO_ADDR, 1n]], OTHER_ADDR),
v4FiveEth(false),
],
);
expect(detail(result, "Token In").value).toBe("ETH (native)");
expect(linesBetweenTokens(result)).toEqual([
["Amount", "5.0000 ETH"],
...zeroAddressLines("1", OTHER_ADDR),
]);
});
});
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in // https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in