fix: each PERMIT2_TRANSFER_FROM shows the amount it moves and where to (closes #611)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s

The swap decoder now gives three lines for every PERMIT2_TRANSFER_FROM
it reads, after the swap's Amount line: the amount moved from the
sender, the token with its address, and where it goes (the router, the
V2 pair the next swap spends, the sender, or the address itself on a
line of its own). Before, a move to another address or to the next V2
pair showed no amount at all. Who counts as paid, and so Min. received
against Min. paid out, is unchanged. tokenMovedLine() moves to module
level and takes its label as an argument.

Model: opus-5-5
This commit is contained in:
2026-10-19 20:31:32 +00:00
parent 30dca1454e
commit 66cae2e593
3 changed files with 228 additions and 36 deletions
+66 -21
View File
@@ -488,6 +488,21 @@ 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.
function tokenMovedLine(label, token, sources) {
const info = tokenInfo(token, sources);
if (!present(info.address)) {
return { label, value: info.symbol };
}
return {
label,
value: info.symbol ? info.symbol + " (" + token + ")" : token,
address: token,
isToken: true,
};
}
// Decode PERMIT2_PERMIT (command 0x0a) input bytes.
// ABI: ((address token, uint160 amount, uint48 expiration, uint48 nonce),
// address spender, uint256 sigDeadline), bytes signature
@@ -1171,6 +1186,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// router, as { token, amount }. Later steps may leave part of it
// there: nothing here checks that they spend all of it.
const movedIntoRouter = [];
// What each PERMIT2_TRANSFER_FROM moves from the sender, in step
// order, whoever counts as paid, as { token, recipient, amount, to }:
// `to` says where it goes, the sender, the router or the V2 pair the
// next swap spends, and is null when it goes to `recipient` as
// written.
const movedFromSender = [];
// Each token a step paid to the router that no later step paid out
// or spent in full, by its address, lowercased, ETH as the zero
// address. The router is paid what a swap step, V4 TAKE or
@@ -1302,10 +1323,24 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// pays address(1) and address(2) as written (see
// decodePermit2TransferFrom()).
addPaidElsewhere(p.recipient);
movedFromSender.push({ ...p, to: null });
} else if (isRouter(p.recipient)) {
movedIntoRouter.push(p);
} else if (!isHop(i, p.token, p.recipient)) {
movedFromSender.push({ ...p, to: "The router" });
} else if (isHop(i, p.token, p.recipient)) {
movedFromSender.push({
...p,
to: "The V2 pair the next swap spends",
});
} else {
addPayee(p.recipient);
movedFromSender.push({
...p,
to:
p.recipient.toLowerCase() === MSG_SENDER
? "The sender"
: null,
});
}
} else if (command === "BALANCE_CHECK_ERC20") {
const b = decodeBalanceCheck(inputs[i]);
@@ -1675,6 +1710,26 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
});
}
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
// where it goes, an address on a line of its own.
for (const m of movedFromSender) {
details.push({
label: "Moved from the sender",
value: amountText(m.amount, tokenInfo(m.token, sources))
.display,
});
details.push(tokenMovedLine("Token moved", m.token, sources));
details.push(
present(m.to)
? { label: "Moved to", value: m.to }
: {
label: "Moved to",
value: m.recipient,
address: m.recipient,
},
);
}
// 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
@@ -1723,24 +1778,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (bridgeDeposit) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
}
// The line giving a token in the router, in the shape of the Token In
// line, so the screens show it as they show that one. ETH has no
// address.
const tokenMovedLine = (token) => {
const info = tokenInfo(token, sources);
if (!present(info.address)) {
return {
label: "Token moved into the router",
value: info.symbol,
};
}
return {
label: "Token moved into the router",
value: info.symbol ? info.symbol + " (" + token + ")" : token,
address: token,
isToken: true,
};
};
for (const m of movedIntoRouter) {
const info = tokenInfo(m.token, sources);
paidTo.push({
@@ -1750,7 +1787,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
amountText(m.amount, info).display +
" from the sender (later steps may not spend all of it)",
});
paidTo.push(tokenMovedLine(m.token));
paidTo.push(
tokenMovedLine("Token moved into the router", m.token, sources),
);
}
if (outputHeld) {
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
@@ -1764,7 +1803,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
continue;
}
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
paidTo.push(tokenMovedLine(getAddress(token)));
paidTo.push(
tokenMovedLine(
"Token moved into the router",
getAddress(token),
sources,
),
);
}
if (payeeUnread) {
paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY });