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 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 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 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` its token or a token the decoder does not know, and the decoder reads every
comes after it. Permit2 then lets the router take no more of the token than step after it in full. Permit2 then lets the router take no more of the token
that amount, so the permit covers every take of it. In any other case the line than that amount, so the permit covers every take of it. A step the decoder
gives the total, as with no permit. When the step that sets the input side does not read in full may raise what Permit2 lets the router take, as a
spends a token other than the permit's, or one the decoder does not know, its `PERMIT2_PERMIT_BATCH` can: a command the decoder does not read (listed at the
amount gets lines of its own and the total has no figure to start from: unless end of this section), a step whose input it cannot read, in whole or in part,
the permit's amount stands there is no `Amount` line, and each take of the and a step it fails on. In any other case the line gives the total, as with no
permit's token gets lines of its own. Each take the total does not count gives permit. When the step that sets the input side spends a token other than the
three lines after the `Amount` line (after `Token In` when there is none): one permit's, or one the decoder does not know, its amount gets lines of its own
of a token other than `Token In`; one of `Token In` after an amount that is and the total has no figure to start from: unless the permit's amount stands
not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their wording on there is no `Amount` line, and each take of the permit's token gets lines of
the `Amount` line, or no stated amount, as of a V4 exact-out step); and one its own. Each take the total does not count gives three lines after the
whose amount the decoder does not know. They come in step order, the step that `Amount` line (after `Token In` when there is none): one of a token other than
sets the input side first when it has them: `Moved from the sender`, its `Token In`; one of `Token In` after an amount that is not a quantity (a V4
amount, worded as the `Amount` line words one, or open delta, `ALREADY_PAID`, which keep their wording on the `Amount` line, or
`Unknown (could not be decoded)` when the decoder does not know it; no stated amount, as of a V4 exact-out step); and one whose amount the decoder
`Token moved`, giving the token's address as the `Token In` line does; and does not know. They come in step order, the step that sets the input side
`Moved to`, which reads `The swap step that spends it`. A token the decoder first when it has them: `Moved from the sender`, its amount, worded as the
does not know gives the `Moved from the sender` line alone. A step may also `Amount` line words one, or `Unknown (could not be decoded)` when the decoder
take from the user's wallet what the decoder did not read: a V4 step, other does not know it; `Token moved`, giving the token's address as the `Token In`
than the one that sets the input side, whose input or one of whose `SETTLE`s line does; and `Moved to`, which reads `The swap step that spends it`. A token
or `SETTLE_ALL`s it cannot read, which the router still runs; a V2 or V3 the decoder does not know gives the `Moved from the sender` line alone. A step
exact-in or V2 exact-out step or a `PERMIT2_TRANSFER_FROM` whose input it may also take from the user's wallet what the decoder did not read: a V4 step,
cannot read; and every `PERMIT2_TRANSFER_FROM_BATCH`. Each such step gives one other than the one that sets the input side, whose input or one of whose
`Moved from the sender` line among those, reading `SETTLE`s or `SETTLE_ALL`s it cannot read, which the router still runs; a V2
`Unknown (could not be decoded)`. That line, or any line for a token the or V3 exact-in or V2 exact-out step or a `PERMIT2_TRANSFER_FROM` whose input
decoder does not know, leaves no `Amount` line, whose figure might be too low. it cannot read; every `PERMIT2_TRANSFER_FROM_BATCH`; and, once the input side
Then each `PERMIT2_TRANSFER_FROM` but one into the router, in step order, is set, every `V3_SWAP_EXACT_OUT`, which the decoder does not read, and every
gives three lines: `Moved from the sender`, the amount it moves, for example step it fails on. Each such step gives one `Moved from the sender` line among
`1000000.0000 USDT`; `Token moved`, giving the token's address as the 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 `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)` `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 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 // The last PERMIT2_PERMIT this decoder read, or null (see
// permitStands after the loop). // permitStands after the loop).
let permit = null; 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 outputToken = null;
let minOutput = null; let minOutput = null;
let outputEstablished = false; 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 // 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 // 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 // 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 // exact-in or V2 exact-out step or PERMIT2_TRANSFER_FROM whose input
// PERMIT2_TRANSFER_FROM_BATCH. It is recorded with a null token and // it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; and, once the input
// amount: one line says so, and there is no Amount line, whose figure // side is set, a V3_SWAP_EXACT_OUT or a step it fails on. It is
// may then be too low. // 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 = () => { const spentUnread = () => {
userPaid.push({ token: null, amount: null }); userPaid.push({ token: null, amount: null });
}; };
@@ -1371,6 +1376,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
hasSubPlan = true; hasSubPlan = true;
commandNames[i] += " (its steps are not shown)"; commandNames[i] += " (its steps are not shown)";
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i);
} else if (command === "PERMIT2_PERMIT") { } else if (command === "PERMIT2_PERMIT") {
const p = decodePermit2(inputs[i]); const p = decodePermit2(inputs[i]);
if (p) { if (p) {
@@ -1389,11 +1395,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
allowedToFail, allowedToFail,
beforeEveryTake: !tookBefore, beforeEveryTake: !tookBefore,
}; };
} 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]);
if (!p) { if (!p) {
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i);
spentUnread(); spentUnread();
} else if ( } else if (
commandTypeMask === 0x1f && commandTypeMask === 0x1f &&
@@ -1432,6 +1441,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// owner may be any address. It sets no side, and the // owner may be any address. It sets no side, and the
// step says so. // step says so.
commandNames[i] += " (could not be decoded)"; commandNames[i] += " (could not be decoded)";
stepsUnread.push(i);
} else if (b.owner.toLowerCase() !== sender) { } else if (b.owner.toLowerCase() !== sender) {
// The router reads the owner as written (see // The router reads the owner as written (see
// decodeBalanceCheck()), so address(1) and address(2) // decodeBalanceCheck()), so address(1) and address(2)
@@ -1473,8 +1483,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
} else { } else {
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i);
spentUnread(); 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") { } else if (command === "V2_SWAP_EXACT_IN") {
const s = decodeV2SwapExactIn(inputs[i]); const s = decodeV2SwapExactIn(inputs[i]);
if (s) { if (s) {
@@ -1492,6 +1511,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
} else { } else {
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i);
spentUnread(); spentUnread();
} }
} else if (command === "V2_SWAP_EXACT_OUT") { } else if (command === "V2_SWAP_EXACT_OUT") {
@@ -1510,12 +1530,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
} else { } else {
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i);
spentUnread(); spentUnread();
} }
} else if (command === "PERMIT2_TRANSFER_FROM_BATCH") { } else if (command === "PERMIT2_TRANSFER_FROM_BATCH") {
// It moves the sender's tokens to any addresses, and this // It moves the sender's tokens to any addresses, and this
// decoder does not read it. // decoder does not read it.
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i);
spentUnread(); spentUnread();
} else if (command === "SWEEP") { } else if (command === "SWEEP") {
const s = decodeSweep(inputs[i]); const s = decodeSweep(inputs[i]);
@@ -1526,6 +1548,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
} else { } else {
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i);
} }
} else if ( } else if (
command === "TRANSFER" || command === "TRANSFER" ||
@@ -1562,6 +1585,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
} else { } else {
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i);
} }
} else if (command === "ACROSS_V4_DEPOSIT_V3") { } else if (command === "ACROSS_V4_DEPOSIT_V3") {
const a = decodeAcrossV4DepositV3(inputs[i]); const a = decodeAcrossV4DepositV3(inputs[i]);
@@ -1585,6 +1609,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
} else { } else {
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i);
} }
} else if (command === "WRAP_ETH") { } else if (command === "WRAP_ETH") {
const w = decodeWrapEth(inputs[i]); const w = decodeWrapEth(inputs[i]);
@@ -1615,6 +1640,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
} else { } else {
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i);
} }
} else if (command === "V4_SWAP") { } else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]); const v4 = decodeV4Swap(inputs[i]);
@@ -1634,6 +1660,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!v4 || v4.recipientUnread) { if (!v4 || v4.recipientUnread) {
payeeUnread = true; payeeUnread = true;
} }
if (
!v4 ||
v4.recipientUnread ||
v4.settleUnread ||
v4.swapUndecoded
) {
stepsUnread.push(i);
}
if (v4 && v4.swapUndecoded) { if (v4 && v4.swapUndecoded) {
// A swap action of this step went unread, so the step // A swap action of this step went unread, so the step
// is marked. // is marked.
@@ -1701,16 +1735,21 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
} else { } else {
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i);
} }
} else { } else {
// Any other step may pay an address this decoder does not // Any other step may pay an address this decoder does not
// read: V3_SWAP_EXACT_OUT and the NFT commands among them, // read: PERMIT2_PERMIT_BATCH and the NFT commands among
// and a type the release names no command for. // them, and a type the release names no command for.
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i);
} }
} catch { } 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; payeeUnread = true;
stepsUnread.push(i);
if (inputEstablished) spentUnread();
} }
if (allowedToFail) { if (allowedToFail) {
commandNames[i] += " (allowed to fail)"; 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 // 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 // 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 // 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 // its token or one not known, and this decoder read every step after
// it. Permit2 then // it in full (see stepsUnread). Permit2 then lets the router take no
// lets the router take no more of the token than that amount, so the // more of the token than that amount, so the permit covers every take
// permit covers every take of it. A step this decoder does not read // of it. A later step this decoder did not read may raise what Permit2
// that may take from the sender leaves no figure in any case, and an // lets the router take, as a PERMIT2_PERMIT_BATCH can. A step this
// EXECUTE_SUB_PLAN step leaves both sides unknown (below). // 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( const stepCommands = Array.from(
commandsBytes, commandsBytes,
(b) => commands[b & commandTypeMask], (b) => commands[b & commandTypeMask],
@@ -1747,9 +1789,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
sameAddress(permit.spender, toAddress) && sameAddress(permit.spender, toAddress) &&
!permit.allowedToFail && !permit.allowedToFail &&
permit.beforeEveryTake && permit.beforeEveryTake &&
!stepCommands !stepsUnread.some((step) => step > permit.step);
.slice(permit.step + 1)
.includes("PERMIT2_PERMIT_BATCH");
// What the steps take that the Amount line does not give, as // 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 // { token, amount }. A null token is a step this decoder did not read
// (see spentUnread()), or a token it does not know. // (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, // 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 // because the amount of the step that set the input side is not a
// quantity, gets lines of its own. // quantity, gets lines of its own.