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 was merged in pull request #651.
This commit is contained in:
2026-10-21 00:56:41 +02:00
parent e517219038
commit 45aead6340
4 changed files with 396 additions and 115 deletions
+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
// line, so the screens show it as they show that one. ETH has no address.
// The line giving a moved token, or the token of a permit, under `label`, in
// 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) {
const info = tokenInfo(token, sources);
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
// 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
// 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) {
const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex;
if (hex.length < 40 || (hex.length - 40) % 46 !== 0) return null;
const first = "0x" + hex.slice(0, 40);
const last = "0x" + hex.slice(-40);
const first = getAddress("0x" + hex.slice(0, 40));
const last = getAddress("0x" + hex.slice(-40));
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
// input side with no amount, the one case in which this is read.
let inputPaidByUser = false;
// The last PERMIT2_PERMIT this decoder read, or null (see
// permitStands after the loop).
// 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 = [];
// 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).
@@ -1268,8 +1276,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// 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
// 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
// formed there, in one place (see permitStands).
// loop, one whose amount stands as the Amount figure names Token In,
// and the Amount line is formed there, in one place (see
// permitStands).
const setInput = (token, amount) => {
inputToken = present(token) ? token : null;
inputAmount = present(amount) ? amount : null;
@@ -1465,24 +1474,33 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "PERMIT2_PERMIT") {
const p = decodePermit2(inputs[i]);
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
// have: the step that set the input side or one in
// `userPaid`, when its token is the permit's or not
// known.
// have: one in `userPaid`, when its token is the
// permit's or not known. No step has set the input
// side yet.
const mayBeItsToken = (token) =>
!present(token) || sameAddress(token, p.token);
const tookBefore =
(inputEstablished && mayBeItsToken(inputToken)) ||
userPaid.some((t) => mayBeItsToken(t.token));
const tookBefore = userPaid.some((t) =>
mayBeItsToken(t.token),
);
permit = {
...p,
step: i,
allowedToFail,
beforeEveryTake: !tookBefore,
};
keepsRouterWeth = true;
} else {
stepsUnread.push(i);
}
} else if (command === "PERMIT2_TRANSFER_FROM") {
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
// total unknown, with no figure.
//
// The last PERMIT2_PERMIT read names Token In. Its amount stands as
// the Amount figure in place of the total only in the plain shape of
// Uniswap's own routes: it is the only PERMIT2_PERMIT, its spender is
// the router, the router may not let it fail, no step before it took
// its token or one not known, and this decoder read every step after
// it in full (see stepsUnread). Permit2 then lets the router take no
// more of the token than that amount, so the permit covers every take
// of it. A later step this decoder did not read may raise what Permit2
// lets the router take, as a PERMIT2_PERMIT_BATCH can. 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).
// A PERMIT2_PERMIT names Token In only when its amount stands as the
// Amount figure in place of the total, which it does only in the
// plain shape of Uniswap's own routes: it comes before the step that
// set the input side, it is the only PERMIT2_PERMIT in the
// transaction, its spender is the router, the router may not let it
// fail, no step before it took its token or one not known, and this
// decoder read every step after it in full (see stepsUnread). Permit2
// then lets the router take no more of the token than that amount, so
// the permit covers every take of it. A later step this decoder did
// not read may raise what Permit2 lets the router take, as a
// PERMIT2_PERMIT_BATCH can. 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 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(
commandsBytes,
(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
// starts from it and it stated no amount (see below), or null.
let firstTakeUnstated = null;
if (permit === null || sameAddress(inputToken, permit.token)) {
if (!permitStands || sameAddress(inputToken, permit.token)) {
total = inputAmount;
if (hasFirstTake && !present(inputAmount)) {
firstTakeUnstated = { token: inputToken, amount: null };
}
} else if (hasFirstTake) {
// 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 });
}
if (permit !== null) inputToken = permit.token;
if (permitStands) inputToken = permit.token;
for (const t of userPaid) {
const ofTokenIn = sameAddress(t.token, inputToken);
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
// or pay the user, before or after any other step, and a permit among
// them would name Token In. What the other steps state is
// then not the whole swap, so both sides are set unknown, with no
// figure, wherever the step stands.
// or pay the user, before or after any other step. What the other
// steps state is then not the whole swap, so both sides are set
// unknown, with no figure, wherever the step stands.
if (hasSubPlan) {
inputToken = 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
// bundled list has no symbol, and gating the line on one dropped it
// entirely, leaving a Min. received figure with nothing saying what is