fix: an allowance to another spender leaves no Amount figure for its token and no Min. received (closes #658)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

Code that a step runs, such as a V4 pool's hook, can call a spender other
than the router, which can then take up to its allowance from the user's
wallet before the transaction ends. The Amount figure and Min. received took
no account of it.

In decode(), an allowance of Token In to a spender other than the router
now leaves no Amount line, as an unread permit already did, so the swap
steps' own takes get lines of their own. Any allowance to another spender,
or a permit or batch the decoder cannot read, makes the minimum read
Min. paid out. Permits to the router read as before.

Model: opus-5-5
This commit was merged in pull request #666.
This commit is contained in:
2026-10-21 06:24:51 +02:00
parent e2965469ba
commit 4be625dc06
4 changed files with 234 additions and 91 deletions
+31 -11
View File
@@ -1355,8 +1355,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
};
// A PERMIT2_PERMIT or PERMIT2_PERMIT_BATCH at `step` whose input this
// decoder cannot read may let any spender take any amount of any
// token: one line says so, and there is no Amount line, whose figure
// may then be too low.
// token: one line says so, there is no Amount line, whose figure may
// then be too low, and the minimum reads Min. paid out (see
// toAnotherSpender).
const permitUnread = (step) => {
permits.push({ token: null, amount: null, spender: null, step });
stepsUnread.push(step);
@@ -2029,8 +2030,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// 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 so does a permit
// or batch whose input it cannot read (see permitUnread()). An
// no figure, permit or not (see spentUnread()), and so do a permit
// or batch whose input it 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(
commandsBytes,
@@ -2216,16 +2218,30 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
return amountText(amount, info);
};
// Each allowance to a spender other than the router. Code that a step
// runs, such as a V4 pool's hook or a token's own code, can call that
// spender, which can then take up to the allowance from the sender
// before the transaction ends. A permit or batch this decoder could
// not read may be to any spender, of any token.
const toAnotherSpender = permits.filter(
(p) => !sameAddress(p.spender, toAddress),
);
// A null token in `spentFromSender`, a step this decoder did not read
// or a token it does not know, and one in `permits`, a permit or
// batch it could not read, leave no Amount figure, which may then be
// 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
// too low. With no Amount line, each take the total adds up gets
// lines of its own, first, so that every amount this decoder read is
// shown.
if (
present(inputAmount) &&
spentFromSender.every((s) => present(s.token)) &&
permits.every((p) => present(p.token))
toAnotherSpender.every(
(p) =>
present(p.token) &&
present(inputToken) &&
!sameAddress(p.token, inputToken),
)
) {
const amount = spentText(inputAmount, inInfo);
details.push({
@@ -2346,9 +2362,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// step pays the bridge, when one moves the sender's tokens into the
// router, when a token stays in the router, and when a payout could
// not be read. The output is received by the sender only when some
// step pays the sender and there is no such line. When no step names
// anyone paid, one line says so. `paidTo` holds these lines and the
// token line after each line about a token in the router.
// step pays the sender, there is no such line, and no allowance goes
// to another spender (see toAnotherSpender). When no step names anyone
// paid, one line says so. `paidTo` holds these lines and the token
// line after each line about a token in the router.
const outputHeld =
present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
const paidTo = paidElsewhere.map((address) => ({
@@ -2395,7 +2412,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (payeeUnread) {
paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY });
}
const received = paidToSender && paidTo.length === 0;
const received =
paidToSender &&
paidTo.length === 0 &&
toAnotherSpender.length === 0;
if (!paidToSender && paidTo.length === 0) {
paidTo.push({ label: "Paid to", value: UNNAMED_CURRENCY });
}