1 Commits
Author SHA1 Message Date
clawbot 41dfdc8701 fix: a swap split across steps the user pays for shows every amount it spends (closes #625)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The swap's Amount line took only the first swap step's amount. It now
gives the total the steps take of Token In: the amount of the step that
sets the input side plus what each later step the user pays takes of it.
A take of another token, one after an amount that is not a quantity, or
one the decoder cannot tell, gets lines of its own; an unread take, a
V3_SWAP_EXACT_OUT's included, leaves no figure. A V4 step counts every
SETTLE paid by the user and every SETTLE_ALL. A PERMIT2_PERMIT names
Token In, and its amount stands only in the plain shape of Uniswap's own
routes, with every later step read.

Model: opus-5-5
2026-10-20 02:11:22 +00:00
3 changed files with 154 additions and 46 deletions
+34 -30
View File
@@ -3153,36 +3153,40 @@ Currently supported:
included, 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 takes
its token or a token the decoder does not know, and no `PERMIT2_PERMIT_BATCH`
comes after it. Permit2 then lets the router take no more of the token than
that amount, so the permit covers every take of it. In any other case the line
gives the total, as with no permit. When the step that sets the input side
spends a token other than the permit's, or one the decoder does not know, its
amount gets lines of its own and the total has no figure to start from: unless
the permit's amount stands there is no `Amount` line, and each take of the
permit's token gets lines of its own. Each take the total does not count gives
three lines after the `Amount` line (after `Token In` when there is none): one
of a token other than `Token In`; one of `Token In` after an amount that is
not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their wording on
the `Amount` line, or no stated amount, as of a V4 exact-out step); and one
whose amount the decoder does not know. They come in step order, the step that
sets the input side first when 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
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, other
than the one that sets the input side, whose input or one of whose `SETTLE`s
or `SETTLE_ALL`s it cannot read, which the router still runs; a V2 or V3
exact-in or V2 exact-out step or a `PERMIT2_TRANSFER_FROM` whose input it
cannot read; and every `PERMIT2_TRANSFER_FROM_BATCH`. Each such step gives one
`Moved from the sender` line among those, reading
`Unknown (could not be decoded)`. That line, or any line for a token the
decoder does not know, leaves no `Amount` line, whose figure might be too low.
Then each `PERMIT2_TRANSFER_FROM` but one into the router, in step order,
gives three lines: `Moved from the sender`, the amount it moves, for example
`1000000.0000 USDT`; `Token moved`, giving the token's address as the
its token or a token the decoder does not know, and the decoder reads every
step after it in full. Permit2 then lets the router take no more of the token
than that amount, so the permit covers every take of it. A step the decoder
does not read in full may raise what Permit2 lets the router take, as a
`PERMIT2_PERMIT_BATCH` can: a command the decoder does not read (listed at the
end of this section), a step whose input it cannot read, in whole or in part,
and a step it fails on. In any other case the line gives the total, as with no
permit. When the step that sets the input side spends a token other than the
permit's, or one the decoder does not know, its amount gets lines of its own
and the total has no figure to start from: unless the permit's amount stands
there is no `Amount` line, and each take of the permit's token gets lines of
its own. Each take the total does not count gives three lines after the
`Amount` line (after `Token In` when there is none): one of a token other than
`Token In`; one of `Token In` after an amount that is not a quantity (a V4
open delta, `ALREADY_PAID`, which keep their wording on the `Amount` line, or
no stated amount, as of a V4 exact-out step); and one whose amount the decoder
does not know. They come in step order, the step that sets the input side
first when 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 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,
other than the one that sets the input side, whose input or one of whose
`SETTLE`s or `SETTLE_ALL`s it cannot read, which the router still runs; a V2
or V3 exact-in or V2 exact-out step or a `PERMIT2_TRANSFER_FROM` whose input
it cannot read; every `PERMIT2_TRANSFER_FROM_BATCH`; and, once the input side
is set, every `V3_SWAP_EXACT_OUT`, which the decoder does not read, and every
step it fails on. Each such step gives one `Moved from the sender` line among
those, reading `Unknown (could not be decoded)`. That line, or any line for a
token the decoder does not know, leaves no `Amount` line, whose figure might
be too low. Then each `PERMIT2_TRANSFER_FROM` but one into the router, in step
order, gives three lines: `Moved from the sender`, the amount it moves, for
example `1000000.0000 USDT`; `Token moved`, giving the token's address as the
`Token In` line does; and `Moved to`, which reads
`The V2 pair the next swap spends` for a hop and `The sender` for `address(1)`
on every router but `UniversalRouterV1`, and otherwise gives the address. A
+56 -16
View File
@@ -1169,6 +1169,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// The last PERMIT2_PERMIT this decoder read, or null (see
// permitStands after the loop).
let permit = null;
// 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).
const stepsUnread = [];
let outputToken = null;
let minOutput = null;
let outputEstablished = false;
@@ -1229,10 +1233,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// A step may take from the sender what this decoder did not read: a
// V4 step, other than the one that sets the input side, whose input
// or one of whose SETTLEs or SETTLE_ALLs it cannot read; a V2 or V3
// swap step or PERMIT2_TRANSFER_FROM whose input it cannot read; or a
// PERMIT2_TRANSFER_FROM_BATCH. It is recorded with a null token and
// amount: one line says so, and there is no Amount line, whose figure
// may then be too low.
// exact-in or V2 exact-out step or PERMIT2_TRANSFER_FROM whose input
// it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; and, once the input
// side is set, a V3_SWAP_EXACT_OUT or a step it fails on. It is
// recorded with a null token and amount: one line says so, and there
// is no Amount line, whose figure may then be too low.
const spentUnread = () => {
userPaid.push({ token: null, amount: null });
};
@@ -1371,6 +1376,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
hasSubPlan = true;
commandNames[i] += " (its steps are not shown)";
payeeUnread = true;
stepsUnread.push(i);
} else if (command === "PERMIT2_PERMIT") {
const p = decodePermit2(inputs[i]);
if (p) {
@@ -1389,11 +1395,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
allowedToFail,
beforeEveryTake: !tookBefore,
};
} else {
stepsUnread.push(i);
}
} else if (command === "PERMIT2_TRANSFER_FROM") {
const p = decodePermit2TransferFrom(inputs[i]);
if (!p) {
payeeUnread = true;
stepsUnread.push(i);
spentUnread();
} else if (
commandTypeMask === 0x1f &&
@@ -1432,6 +1441,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// owner may be any address. It sets no side, and the
// step says so.
commandNames[i] += " (could not be decoded)";
stepsUnread.push(i);
} else if (b.owner.toLowerCase() !== sender) {
// The router reads the owner as written (see
// decodeBalanceCheck()), so address(1) and address(2)
@@ -1473,8 +1483,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
payeeUnread = true;
stepsUnread.push(i);
spentUnread();
}
} else if (command === "V3_SWAP_EXACT_OUT") {
// This decoder does not read it
// (https://git.eeqj.de/sneak/AutistMask/issues/601). It
// may pay any address and take from the sender, so once
// the input side is set it leaves no Amount figure.
payeeUnread = true;
stepsUnread.push(i);
if (inputEstablished) spentUnread();
} else if (command === "V2_SWAP_EXACT_IN") {
const s = decodeV2SwapExactIn(inputs[i]);
if (s) {
@@ -1492,6 +1511,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
payeeUnread = true;
stepsUnread.push(i);
spentUnread();
}
} else if (command === "V2_SWAP_EXACT_OUT") {
@@ -1510,12 +1530,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
payeeUnread = true;
stepsUnread.push(i);
spentUnread();
}
} else if (command === "PERMIT2_TRANSFER_FROM_BATCH") {
// It moves the sender's tokens to any addresses, and this
// decoder does not read it.
payeeUnread = true;
stepsUnread.push(i);
spentUnread();
} else if (command === "SWEEP") {
const s = decodeSweep(inputs[i]);
@@ -1526,6 +1548,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
payeeUnread = true;
stepsUnread.push(i);
}
} else if (
command === "TRANSFER" ||
@@ -1562,6 +1585,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
payeeUnread = true;
stepsUnread.push(i);
}
} else if (command === "ACROSS_V4_DEPOSIT_V3") {
const a = decodeAcrossV4DepositV3(inputs[i]);
@@ -1585,6 +1609,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
payeeUnread = true;
stepsUnread.push(i);
}
} else if (command === "WRAP_ETH") {
const w = decodeWrapEth(inputs[i]);
@@ -1615,6 +1640,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
payeeUnread = true;
stepsUnread.push(i);
}
} else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]);
@@ -1634,6 +1660,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!v4 || v4.recipientUnread) {
payeeUnread = true;
}
if (
!v4 ||
v4.recipientUnread ||
v4.settleUnread ||
v4.swapUndecoded
) {
stepsUnread.push(i);
}
if (v4 && v4.swapUndecoded) {
// A swap action of this step went unread, so the step
// is marked.
@@ -1701,16 +1735,21 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
payeeUnread = true;
stepsUnread.push(i);
}
} else {
// Any other step may pay an address this decoder does not
// read: V3_SWAP_EXACT_OUT and the NFT commands among them,
// and a type the release names no command for.
// read: PERMIT2_PERMIT_BATCH and the NFT commands among
// them, and a type the release names no command for.
payeeUnread = true;
stepsUnread.push(i);
}
} catch {
// Skip commands we can't decode. One may pay any address.
// Skip commands we can't decode. One may pay any address and
// take from the sender.
payeeUnread = true;
stepsUnread.push(i);
if (inputEstablished) spentUnread();
}
if (allowedToFail) {
commandNames[i] += " (allowed to fail)";
@@ -1731,12 +1770,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// 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 no PERMIT2_PERMIT_BATCH comes after
// it. Permit2 then
// lets the router take no more of the token than that amount, so the
// permit covers every take of it. A step this decoder does not read
// that may take from the sender leaves no figure in any case, and an
// EXECUTE_SUB_PLAN step leaves both sides unknown (below).
// 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 this
// decoder does not read that may take from the sender leaves no
// figure, permit or not, when it comes after the step that sets the
// input side (see spentUnread()), and an EXECUTE_SUB_PLAN step leaves
// both sides unknown (below).
const stepCommands = Array.from(
commandsBytes,
(b) => commands[b & commandTypeMask],
@@ -1747,9 +1789,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
sameAddress(permit.spender, toAddress) &&
!permit.allowedToFail &&
permit.beforeEveryTake &&
!stepCommands
.slice(permit.step + 1)
.includes("PERMIT2_PERMIT_BATCH");
!stepsUnread.some((step) => step > permit.step);
// What the steps take that the Amount line does not give, as
// { token, amount }. A null token is a step this decoder did not read
// (see spentUnread()), or a token it does not know.
+64
View File
@@ -1830,6 +1830,70 @@ describe("uniswap decoder", () => {
]);
});
// Any other step this decoder does not read may do the same, for
// example through the hook of the pool a V4_INITIALIZE_POOL sets up.
test.each([
{
kind: "a V4_INITIALIZE_POOL",
command: 0x13,
input: coder.encode(
[POOL_KEY, "uint160"],
[
[USDC_ADDR, USDT_ADDR, 3000, 60, OTHER_ADDR],
79228162514264337593543950336n,
],
),
},
{
kind: "a SWEEP whose input cannot be read",
command: 0x04,
input: dirtyWord(
coder.encode(
["address", "address", "uint256"],
[USDC_ADDR, USER_ADDR, 0n],
),
1,
),
},
])(
"a split after a PERMIT2_PERMIT, with $kind between its steps, shows the total the steps take",
({ command, input }) => {
const result = decodeSteps(
[0x0a, 0x00, command, 0x08],
[
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
V3_600,
input,
encodeV2SwapExactIn(USER_ADDR, 9400000000n, 1n, [
USDT_ADDR,
USDC_ADDR,
]),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"],
]);
},
);
// This decoder does not read a V3_SWAP_EXACT_OUT, so what one takes
// after the step that sets the input side leaves no Amount figure.
test("a V3 exact-in of 600 USDT, then a V3_SWAP_EXACT_OUT the user pays for, shows no Amount figure", () => {
// Its input is (recipient, amountOut, amountInMax, path,
// payerIsUser), laid out as encodeV3SwapExactIn() writes one; an
// exact-out path lists the token bought first.
const exactOut = encodeV3SwapExactIn(
USER_ADDR,
390000000n,
USDT_400,
[USDC_ADDR, USDT_ADDR],
);
const result = decodeSteps([0x00, 0x01], [V3_600, exactOut]);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
]);
});
// A later step's amount that cannot be added to the Amount line,
// because the amount of the step that set the input side is not a
// quantity, gets lines of its own.