fix: only a permit whose amount is the Amount figure names 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 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
+63 -50
View File
@@ -1129,13 +1129,15 @@ scale is not an approximation but a different number — 1,000 units of a
6-decimal token formatted at 18 decimals reads `0.000000001` — on the screen 6-decimal token formatted at 18 decimals reads `0.000000001` — on the screen
whose only job is to state what is being authorized. Both amount paths of that whose only job is to state what is being authorized. Both amount paths of that
screen take this rule: the ERC-20 `transfer`/`approve` line screen take this rule: the ERC-20 `transfer`/`approve` line
(`src/popup/views/approval.js`) and the swap's `Amount` and `Min. received` (`src/popup/views/approval.js`) and the swap's `Amount`, `Min. received`,
lines (`src/shared/uniswap.js`). The token permission warning on the signature `Moved from the sender` and `Permit2 allowance` lines and the `Paid to` line of
screen takes the same rule for its amounts. An unbounded allowance or permit a move into the router (`src/shared/uniswap.js`). The token permission warning
needs no scale to describe and is still shown as `Unlimited`. A source's answer on the signature screen takes the same rule for its amounts. An unbounded
counts only if it is a whole number from 0 to 80: `decimals()` returns a allowance or permit needs no scale to describe and is still shown as
`uint8`, but `formatUnits()` cannot format more than 80 decimal places, so a `Unlimited`. A source's answer counts only if it is a whole number from 0 to 80:
token that reports 81 to 255 is shown as one whose scale nothing knows. `decimals()` returns a `uint8`, but `formatUnits()` cannot format more than 80
decimal places, so a token that reports 81 to 255 is shown as one whose scale
nothing knows.
The rule holds only if nothing invents a scale UPSTREAM of it. Those three The rule holds only if nothing invents a scale UPSTREAM of it. Those three
sources are read as authoritative, so a value written into one of them cannot be sources are read as authoritative, so a value written into one of them cannot be
@@ -2425,10 +2427,11 @@ view would leave a wallet one click from deletion.
transaction" transaction"
- Decoded action (if calldata is recognized): action name, token details, - Decoded action (if calldata is recognized): action name, token details,
amounts, what each swap step spends that the `Amount` line does not give, amounts, what each swap step spends that the `Amount` line does not give,
what each `PERMIT2_TRANSFER_FROM` moves and where to, steps, deadline, and what each `PERMIT2_TRANSFER_FROM` moves and where to, what each
unless a swap pays only the From address, a `Paid to` line for each other `PERMIT2_PERMIT` whose amount is not the `Amount` figure lets its spender
address it pays, or saying that a token stays in the router or who it pays take, steps, deadline, and unless a swap pays only the From address, a
is unknown (see Transaction Decoding) `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)
- From: color dot + full address + etherscan link - From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if - Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx known; for a contract creation, the same sentence as on WaitTx
@@ -3217,40 +3220,42 @@ Currently supported:
added to the total while both are quantities: a V3 exact-in of 600 USDT and a added to the total while both are quantities: a V3 exact-in of 600 USDT and a
V2 exact-in of 400 USDT, both paid by the user, read `1000.0000 USDT`. When V2 exact-in of 400 USDT, both paid by the user, read `1000.0000 USDT`. When
the decoder does not know a take's amount, the total is not known and there is the decoder does not know a take's amount, the total is not known and there is
no `Amount` line. `Token In` is the token of the last `PERMIT2_PERMIT` the no `Amount` line. A `PERMIT2_PERMIT` names `Token In` only when its amount,
decoder reads, when there is one. Its amount, `Unlimited` included, stands as `Unlimited` included, stands as the `Amount` figure in place of the total,
the `Amount` figure in place of the total only in the plain shape of Uniswap's which it does only in the plain shape of Uniswap's own routes: it comes before
own routes: it is the only `PERMIT2_PERMIT`, its spender is the router, the the step that sets the input side, it is the only `PERMIT2_PERMIT` in the
router may not let it fail, no step before it takes its token or a token the transaction, its spender is the router, the router may not let it fail, no
decoder does not know, and the decoder reads every step after it in full. step before it takes its token or a token the decoder does not know, and the
Permit2 then lets the router take no more of the token than that amount, so decoder reads every step after it in full. Permit2 then lets the router take
the permit covers every take of it. A step the decoder does not read in full no more of the token than that amount, so the permit covers every take of it.
may raise what Permit2 lets the router take, as a `PERMIT2_PERMIT_BATCH` can: A step the decoder does not read in full may raise what Permit2 lets the
a command the decoder does not read (listed at the end of this section), a router take, as a `PERMIT2_PERMIT_BATCH` can: a command the decoder does not
step whose input it cannot read, in whole or in part, and a step it fails on. read (listed at the end of this section), a step whose input it cannot read,
In any other case the line gives the total, as with no permit. When the step in whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`,
that sets the input side spends a token other than the permit's, or one the before the step that sets the input side or after it, leaves `Token In` and
decoder does not know, its amount gets lines of its own, unless it states no the `Amount` line as they read with no permit: a permit of 5,000 DAI to
amount and the user pays it none of its input token, and the total has no another address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC`
figure to start from: unless the permit's amount stands there is no `Amount` with `Amount 600.0000 USDT`, and so do that exact-in and then a permit of
line, and each take of the permit's token gets lines of its own. Each take the 5,000 DAI to the router. When a permit's amount stands and the step that sets
total does not count gives three lines after the `Amount` line (after the input side spends a token other than the permit's, or one the decoder does
`Token In` when there is none): one of a token other than `Token In`; one of not know, that step's amount gets lines of its own, unless it states no amount
`Token In` after an amount that is not a quantity (a V4 open delta, and the user pays it none of its input token. Each take the total does not
`ALREADY_PAID`, which keep their wording on the `Amount` line, or no stated count gives three lines after the `Amount` line (after `Token In` when there
amount, as of a V4 exact-out step); and one whose amount the decoder does not is none): one of a token other than `Token In`; one of `Token In` after an
know. When the step that sets the input side states no amount, the user pays amount that is not a quantity (a V4 open delta, `ALREADY_PAID`, which keep
it some of its input token, and there is no `Amount` line, that step gets them their wording on the `Amount` line, or no stated amount, as of a V4 exact-out
too whenever another take's lines give a figure, or a `PERMIT2_TRANSFER_FROM` step); and one whose amount the decoder does not know. When the step that sets
of its token gives one, on the lines below or, into the router, on its the input side states no amount, the user pays it some of its input token, and
`Paid to` line, so that figure cannot read as all that leaves. When it states there is no `Amount` line, that step gets them too whenever another take's
no amount and the user pays it none of its input token, as when the router lines give a figure, or a `PERMIT2_TRANSFER_FROM` of its token gives one, on
pays it, that step takes none of that token from the user's wallet and never the lines below or, into the router, on its `Paid to` line, so that figure
gets them, with a permit or without. They come in step order, the step that cannot read as all that leaves. When it states no amount and the user pays it
sets the input side first when it has them: `Moved from the sender`, its none of its input token, as when the router pays it, that step takes none of
amount, worded as the `Amount` line words one, or that token from the user's wallet and never gets them, with a permit or
`Unknown (could not be decoded)` when the decoder does not know it; without. They come in step order, the step that sets the input side first when
`Token moved`, giving the token's address as the `Token In` line does; and it has them: `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; `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
does not know gives the `Moved from the sender` line alone. A step may also does not know gives the `Moved from the sender` line alone. A step may also
take from the user's wallet what the decoder did not read: a V4 step whose take from the user's wallet what the decoder did not read: a V4 step whose
@@ -3271,11 +3276,19 @@ Currently supported:
on every router but `UniversalRouterV1`, and otherwise gives the address. A on every router but `UniversalRouterV1`, and otherwise gives the address. A
move into the router needs none of them, because its `Paid to` line and move into the router needs none of them, because its `Paid to` line and
`Token moved into the router` line already give the amount, the token and `Token moved into the router` line already give the amount, the token and
where it goes, and so its amount is shown once. These lines change neither the where it goes, and so its amount is shown once. Every `PERMIT2_PERMIT` whose
`Paid to` lines nor the minimum's label. A step goes unread when its recipient amount does not stand as the `Amount` figure, before the step that sets the
does not decode as an address, when it is a V3 swap step whose path is shorter input side or after it, gives three lines, in step order, after those for each
than one token address or has bytes after its last hop, or when it is a `PERMIT2_TRANSFER_FROM`: `Permit2 allowance`, its amount, for example
command the decoder does not read: every command other than `PERMIT2_PERMIT`, `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. 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`, `PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
`V3_SWAP_EXACT_IN`, `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `V3_SWAP_EXACT_IN`, `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`,
`PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`,
+83 -37
View File
@@ -494,8 +494,9 @@ function amountText(raw, info) {
}; };
} }
// The line giving a moved token under `label`, in the shape of the Token In // The line giving a moved token, or the token of a permit, under `label`, in
// line, so the screens show it as they show that one. ETH has no address. // the shape of the Token In line, so the screens show it as they show that
// one. ETH has no address.
function tokenMovedLine(label, token, sources) { function tokenMovedLine(label, token, sources) {
const info = tokenInfo(token, sources); const info = tokenInfo(token, sources);
if (!present(info.address)) { if (!present(info.address)) {
@@ -596,12 +597,14 @@ function decodeV2SwapExactOut(input) {
// token(20) ...), or null when it is not one token followed by whole hops of // token(20) ...), or null when it is not one token followed by whole hops of
// a fee and a token (40 hex digits, then 46 per hop). The router reads only // a fee and a token (40 hex digits, then 46 per hop). The router reads only
// whole hops and ignores any bytes after the last one, so the last 20 bytes // whole hops and ignores any bytes after the last one, so the last 20 bytes
// of such a path are not the token it reads there. // of such a path are not the token it reads there. Both are checksummed, as
// the ABI decoder gives every other address, so a token reads the same on
// every line.
function decodeV3Path(pathHex) { function decodeV3Path(pathHex) {
const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex; const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex;
if (hex.length < 40 || (hex.length - 40) % 46 !== 0) return null; if (hex.length < 40 || (hex.length - 40) % 46 !== 0) return null;
const first = "0x" + hex.slice(0, 40); const first = getAddress("0x" + hex.slice(0, 40));
const last = "0x" + hex.slice(-40); const last = getAddress("0x" + hex.slice(-40));
return { first, last }; return { first, last };
} }
@@ -1208,9 +1211,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// its input currency (see decodeV4Swap()). Only a V4 step can set the // its input currency (see decodeV4Swap()). Only a V4 step can set the
// input side with no amount, the one case in which this is read. // input side with no amount, the one case in which this is read.
let inputPaidByUser = false; let inputPaidByUser = false;
// The last PERMIT2_PERMIT this decoder read, or null (see // The last PERMIT2_PERMIT this decoder read before a step set the
// permitStands after the loop). // input side, or null (see permitStands after the loop).
let permit = null; 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 = [];
// The index of each step this decoder did not read in full: a // 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 // command it does not read, a step whose input it cannot read, in
// whole or in part, and one it fails on (see permitStands). // whole or in part, and one it fails on (see permitStands).
@@ -1268,8 +1276,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// when no earlier hop set it. A half the establishing hop did not // when no earlier hop set it. A half the establishing hop did not
// state stays null and the line says so, rather than being filled in // state stays null and the line says so, rather than being filled in
// from a different hop. A PERMIT2_PERMIT sets no side here: after the // from a different hop. A PERMIT2_PERMIT sets no side here: after the
// loop, the last one read names Token In, and the Amount line is // loop, one whose amount stands as the Amount figure names Token In,
// formed there, in one place (see permitStands). // and the Amount line is formed there, in one place (see
// permitStands).
const setInput = (token, amount) => { const setInput = (token, amount) => {
inputToken = present(token) ? token : null; inputToken = present(token) ? token : null;
inputAmount = present(amount) ? amount : null; inputAmount = present(amount) ? amount : null;
@@ -1465,24 +1474,33 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "PERMIT2_PERMIT") { } else if (command === "PERMIT2_PERMIT") {
const p = decodePermit2(inputs[i]); const p = decodePermit2(inputs[i]);
if (p) { if (p) {
permits.push({ ...p, step: i });
keepsRouterWeth = true;
} else {
stepsUnread.push(i);
}
// Only a permit whose amount stands as the Amount figure
// names Token In (see permitStands). Every other permit,
// before the step that sets the input side or after it,
// leaves Token In and Amount as they read with no permit
// and gets lines of its own. One after a step set the input
// side never stands, so only one before it is kept here.
if (p && !inputEstablished) {
// Whether a step before it took its token, or may // Whether a step before it took its token, or may
// have: the step that set the input side or one in // have: one in `userPaid`, when its token is the
// `userPaid`, when its token is the permit's or not // permit's or not known. No step has set the input
// known. // side yet.
const mayBeItsToken = (token) => const mayBeItsToken = (token) =>
!present(token) || sameAddress(token, p.token); !present(token) || sameAddress(token, p.token);
const tookBefore = const tookBefore = userPaid.some((t) =>
(inputEstablished && mayBeItsToken(inputToken)) || mayBeItsToken(t.token),
userPaid.some((t) => mayBeItsToken(t.token)); );
permit = { permit = {
...p, ...p,
step: i, step: i,
allowedToFail, allowedToFail,
beforeEveryTake: !tookBefore, beforeEveryTake: !tookBefore,
}; };
keepsRouterWeth = true;
} else {
stepsUnread.push(i);
} }
} else if (command === "PERMIT2_TRANSFER_FROM") { } else if (command === "PERMIT2_TRANSFER_FROM") {
const p = decodePermit2TransferFrom(inputs[i]); const p = decodePermit2TransferFrom(inputs[i]);
@@ -1939,18 +1957,21 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// lines of its own. One whose amount is not known also leaves the // lines of its own. One whose amount is not known also leaves the
// total unknown, with no figure. // total unknown, with no figure.
// //
// The last PERMIT2_PERMIT read names Token In. Its amount stands as // A PERMIT2_PERMIT names Token In only when its amount stands as the
// the Amount figure in place of the total only in the plain shape of // Amount figure in place of the total, which it does only in the
// Uniswap's own routes: it is the only PERMIT2_PERMIT, its spender is // plain shape of Uniswap's own routes: it comes before the step that
// the router, the router may not let it fail, no step before it took // set the input side, it is the only PERMIT2_PERMIT in the
// its token or one not known, and this decoder read every step after // transaction, its spender is the router, the router may not let it
// it in full (see stepsUnread). Permit2 then lets the router take no // fail, no step before it took its token or one not known, and this
// more of the token than that amount, so the permit covers every take // decoder read every step after it in full (see stepsUnread). Permit2
// of it. A later step this decoder did not read may raise what Permit2 // then lets the router take no more of the token than that amount, so
// lets the router take, as a PERMIT2_PERMIT_BATCH can. A step that // the permit covers every take of it. A later step this decoder did
// may take from the sender what this decoder did not read leaves no // not read may raise what Permit2 lets the router take, as a
// figure, permit or not (see spentUnread()), and an EXECUTE_SUB_PLAN // PERMIT2_PERMIT_BATCH can. Every other PERMIT2_PERMIT leaves Token In
// step leaves both sides unknown (below). // and the Amount line as they read with no permit, and gets lines of
// its own below. A step that may 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).
const stepCommands = Array.from( const stepCommands = Array.from(
commandsBytes, commandsBytes,
(b) => commands[b & commandTypeMask], (b) => commands[b & commandTypeMask],
@@ -1977,17 +1998,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// The take of the step that set the input side, when the total // The take of the step that set the input side, when the total
// starts from it and it stated no amount (see below), or null. // starts from it and it stated no amount (see below), or null.
let firstTakeUnstated = null; let firstTakeUnstated = null;
if (permit === null || sameAddress(inputToken, permit.token)) { if (!permitStands || sameAddress(inputToken, permit.token)) {
total = inputAmount; total = inputAmount;
if (hasFirstTake && !present(inputAmount)) { if (hasFirstTake && !present(inputAmount)) {
firstTakeUnstated = { token: inputToken, amount: null }; firstTakeUnstated = { token: inputToken, amount: null };
} }
} else if (hasFirstTake) { } else if (hasFirstTake) {
// The step that set the input side spent a token other than the // The step that set the input side spent a token other than the
// permit's, or one not known, so the total starts with no figure. // permit's, or one not known, so its amount gets lines of its own.
spentFromSender.push({ token: inputToken, amount: inputAmount }); spentFromSender.push({ token: inputToken, amount: inputAmount });
} }
if (permit !== null) inputToken = permit.token; if (permitStands) inputToken = permit.token;
for (const t of userPaid) { for (const t of userPaid) {
const ofTokenIn = sameAddress(t.token, inputToken); const ofTokenIn = sameAddress(t.token, inputToken);
if (ofTokenIn && permitStands) continue; if (ofTokenIn && permitStands) continue;
@@ -2020,10 +2041,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
// The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens // The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens
// or pay the user, before or after any other step, and a permit among // or pay the user, before or after any other step. What the other
// them would name Token In. What the other steps state is // steps state is then not the whole swap, so both sides are set
// then not the whole swap, so both sides are set unknown, with no // unknown, with no figure, wherever the step stands.
// figure, wherever the step stands.
if (hasSubPlan) { if (hasSubPlan) {
inputToken = null; inputToken = null;
inputAmount = null; inputAmount = null;
@@ -2181,6 +2201,32 @@ 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;
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({
label: "Allowance spender",
value: p.spender,
address: p.spender,
});
}
// Keyed on the address, not the symbol: a token absent from the // Keyed on the address, not the symbol: a token absent from the
// bundled list has no symbol, and gating the line on one dropped it // bundled list has no symbol, and gating the line on one dropped it
// entirely, leaving a Min. received figure with nothing saying what is // entirely, leaving a Min. received figure with nothing saying what is
+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"); const uniswap = require("../src/shared/uniswap");
// A Universal Router on Ethereum Mainnet, chain id 0x1: UniversalRouterV2, // A Universal Router on Ethereum Mainnet, chain id 0x1: UniversalRouterV2,
@@ -696,12 +696,8 @@ describe("uniswap decoder", () => {
USER_ADDR, USER_ADDR,
); );
expect(result.name).toBe("Swap USDT → WETH"); expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Token In").address).toBe( expect(detail(result, "Token In").address).toBe(USDT_ADDR);
USDT_ADDR.toLowerCase(), expect(detail(result, "Token Out").address).toBe(WETH_ADDR);
);
expect(detail(result, "Token Out").address).toBe(
WETH_ADDR.toLowerCase(),
);
const amount = detail(result, "Amount"); const amount = detail(result, "Amount");
expect(amount.value).toBe("Up to 2000.0000 USDT"); expect(amount.value).toBe("Up to 2000.0000 USDT");
@@ -735,9 +731,7 @@ describe("uniswap decoder", () => {
expect(result.name).toBe("Swap ETH → USDC"); expect(result.name).toBe("Swap ETH → USDC");
expect(detail(result, "Amount").value).toBe("Up to 0.5000 ETH"); expect(detail(result, "Amount").value).toBe("Up to 0.5000 ETH");
expect(detail(result, "Moved from the sender")).toBeUndefined(); expect(detail(result, "Moved from the sender")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe( expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
USDC_ADDR.toLowerCase(),
);
expect(detail(result, "Paid to")).toBeUndefined(); expect(detail(result, "Paid to")).toBeUndefined();
expect(detail(result, "Min. received").value).toBe("1500.0000 USDC"); expect(detail(result, "Min. received").value).toBe("1500.0000 USDC");
}); });
@@ -1406,6 +1400,14 @@ describe("uniswap decoder", () => {
return lines.slice(from + 1, to); 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 // execute() of these command bytes and inputs, decoded as sent by
// the user. // the user.
function decodeSteps(commands, inputs) { 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,", 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], commands: [0x10],
inputs: [V4_SETTLE_5000], inputs: [V4_SETTLE_5000],
permit: [],
}, },
{ {
kind: "a PERMIT2_PERMIT of 1 USDT that the router may let fail, then that V4 step,", 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), encodePermit2(USDT_ADDR, 1000000n, ROUTER_ADDR),
V4_SETTLE_5000, V4_SETTLE_5000,
], ],
permit: permitLines(
"1.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
}, },
// The unread SWEEP keeps the permit's amount from standing. // The unread SWEEP keeps the permit's amount from standing.
{ {
@@ -1735,11 +1744,18 @@ describe("uniswap decoder", () => {
1, 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); const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([ expect(linesBetweenTokens(result)).toEqual([
["Amount", "5000.0000 USDT"], ["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 // 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) => const ONE_USDT_PERMIT = (spender) =>
encodePermit2(USDT_ADDR, 1000000n, spender); encodePermit2(USDT_ADDR, 1000000n, spender);
const ONE_USDT_LINES = (spender) =>
permitLines("1.0000 USDT", USDT_ADDR, "USDT", spender);
test.each([ test.each([
{ {
kind: "to another spender", kind: "to another spender",
commands: [0x0a], commands: [0x0a],
permits: [ONE_USDT_PERMIT(OTHER_ADDR)], permits: [ONE_USDT_PERMIT(OTHER_ADDR)],
lines: ONE_USDT_LINES(OTHER_ADDR),
}, },
{ {
kind: "that the router lets fail", kind: "that the router lets fail",
commands: [0x8a], commands: [0x8a],
permits: [ONE_USDT_PERMIT(ROUTER_ADDR)], permits: [ONE_USDT_PERMIT(ROUTER_ADDR)],
lines: ONE_USDT_LINES(ROUTER_ADDR),
}, },
{ {
kind: "followed by a second one", kind: "followed by a second one",
@@ -2044,10 +2065,14 @@ describe("uniswap decoder", () => {
ONE_USDT_PERMIT(ROUTER_ADDR), ONE_USDT_PERMIT(ROUTER_ADDR),
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", "a split after a PERMIT2_PERMIT $kind shows the total the steps take",
({ commands, permits }) => { ({ commands, permits, lines }) => {
const result = decodeSteps( const result = decodeSteps(
[...commands, 0x00, 0x08], [...commands, 0x00, 0x08],
[ [
@@ -2061,10 +2086,20 @@ describe("uniswap decoder", () => {
); );
expect(linesBetweenTokens(result)).toEqual([ expect(linesBetweenTokens(result)).toEqual([
["Amount", "1000.0000 USDT"], ["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 // A PERMIT2_PERMIT_BATCH, which this decoder does not read, may raise
// what the router may take of the permit's token again. // 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", () => { 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([ expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"], ["Amount", "10000.0000 USDT"],
...USDT_600_LINES,
]); ]);
}); });
@@ -2141,6 +2177,7 @@ describe("uniswap decoder", () => {
); );
expect(linesBetweenTokens(result)).toEqual([ expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"], ["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 // 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. // 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, // The permit of 10,000 USDT stands. The one of 600 USDT comes after
// because the step before it took its token. // the step that set the input side, so it does not stand and gets
// lines of its own.
test.each([ test.each([
{ {
kind: "a V3 exact-in of 600 USDT, then a V3_SWAP_EXACT_OUT of at most 400 USDT", 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, V3_600,
], ],
amount: "Up to 10000.0000 USDT", amount: "Up to 10000.0000 USDT",
permit: USDT_600_LINES,
}, },
])( ])(
"$kind, all paid by the user, shows the total as a maximum", "$kind, all paid by the user, shows the total as a maximum",
({ commands, inputs, amount }) => { ({ commands, inputs, amount, permit = [] }) => {
const result = decodeSteps(commands, inputs); const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([ expect(linesBetweenTokens(result)).toEqual([
["Amount", amount], ["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 // 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 // read. It may pay any address and take from the sender, so it
// leaves no Amount figure. // 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 // 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 // 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 // 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", () => { 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 path = pathEndingIn([USDC_ADDR, WETH_ADDR], SPK_ADDR);
const result = decodeSteps( const result = decodeSteps(
@@ -2273,6 +2479,7 @@ describe("uniswap decoder", () => {
); );
expect(linesBetweenTokens(result)).toEqual([ expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED], ["Moved from the sender", UNDECODED],
...permitLines("1.0000 WETH", WETH_ADDR, "WETH", ROUTER_ADDR),
]); ]);
expect(detail(result, "Paid to").value).toBe(UNDECODED); expect(detail(result, "Paid to").value).toBe(UNDECODED);
}); });
@@ -2443,7 +2650,11 @@ describe("uniswap decoder", () => {
V4_EXACT_OUT_600, V4_EXACT_OUT_600,
V2_EXACT_OUT_400, 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", 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); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").address).toBe( expect(detail(result, "Token In").address).toBe(USDT_ADDR);
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Amount").value).toBe("1000.0000 USDT"); expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)", "Unknown (not named in the calldata)",
@@ -3129,9 +3338,7 @@ describe("uniswap decoder", () => {
"Unknown (not named in the calldata)", "Unknown (not named in the calldata)",
); );
expect(detail(result, "Amount")).toBeUndefined(); expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe( expect(detail(result, "Token Out").address).toBe(USDT_ADDR);
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Min. received").value).toBe("990.0000 USDT"); expect(detail(result, "Min. received").value).toBe("990.0000 USDT");
}); });
+19 -4
View File
@@ -2809,7 +2809,7 @@ describe("the allow-revert flag", () => {
mark: " (allowed to fail)", mark: " (allowed to fail)",
}, },
])( ])(
"on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap reads as without the flag", "on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap's lines read as without the flag",
({ address, mark }) => { ({ address, mark }) => {
const steps = [ const steps = [
permit2Permit(USDT, USDT_2000), permit2Permit(USDT, USDT_2000),
@@ -2824,9 +2824,24 @@ describe("the allow-revert flag", () => {
expect(detail(withFlag, "Steps").value).toBe( expect(detail(withFlag, "Steps").value).toBe(
"Permit2 Permit" + mark + " → V3 Swap (Exact In)", "Permit2 Permit" + mark + " → V3 Swap (Exact In)",
); );
const withoutSteps = (r) => // The permit names the router at ROUTER as its spender, so its
r.details.filter((d) => d.label !== "Steps"); // amount stands as the Amount figure only there, and only without
expect(withoutSteps(withFlag)).toEqual(withoutSteps(plain)); // the flag. With the flag it stands on no router and gets lines
// of its own, and the swap step names Token In: the same token,
// its address written the same way.
const permitLines = [
"Permit2 allowance",
"Allowance token",
"Allowance spender",
];
const swapLines = (r) =>
r.details.filter(
(d) => !["Steps", ...permitLines].includes(d.label),
);
expect(swapLines(withFlag)).toEqual(swapLines(plain));
expect(
labels(withFlag).filter((l) => permitLines.includes(l)),
).toEqual(permitLines);
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH"); expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
}, },
); );