1 Commits
Author SHA1 Message Date
clawbot 3b97571a41 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 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, checked in one place after the
loop.

Model: opus-5-5
2026-10-20 01:14:56 +00:00
3 changed files with 46 additions and 154 deletions
+30 -34
View File
@@ -3153,40 +3153,36 @@ 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 the decoder reads every its token or a token the decoder does not know, and no `PERMIT2_PERMIT_BATCH`
step after it in full. Permit2 then lets the router take no more of the token comes after it. Permit2 then lets the router take no more of the token than
than that amount, so the permit covers every take of it. A step the decoder that amount, so the permit covers every take of it. In any other case the line
does not read in full may raise what Permit2 lets the router take, as a gives the total, as with no permit. When the step that sets the input side
`PERMIT2_PERMIT_BATCH` can: a command the decoder does not read (listed at the spends a token other than the permit's, or one the decoder does not know, its
end of this section), a step whose input it cannot read, in whole or in part, amount gets lines of its own and the total has no figure to start from: unless
and a step it fails on. In any other case the line gives the total, as with no the permit's amount stands there is no `Amount` line, and each take of the
permit. When the step that sets the input side spends a token other than the permit's token gets lines of its own. Each take the total does not count gives
permit's, or one the decoder does not know, its amount gets lines of its own three lines after the `Amount` line (after `Token In` when there is none): one
and the total has no figure to start from: unless the permit's amount stands of a token other than `Token In`; one of `Token In` after an amount that is
there is no `Amount` line, and each take of the permit's token gets lines of not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their wording on
its own. Each take the total does not count gives three lines after the the `Amount` line, or no stated amount, as of a V4 exact-out step); and one
`Amount` line (after `Token In` when there is none): one of a token other than whose amount the decoder does not know. They come in step order, the step that
`Token In`; one of `Token In` after an amount that is not a quantity (a V4 sets the input side first when it has them: `Moved from the sender`, its
open delta, `ALREADY_PAID`, which keep their wording on the `Amount` line, or amount, worded as the `Amount` line words one, or
no stated amount, as of a V4 exact-out step); and one whose amount the decoder `Unknown (could not be decoded)` when the decoder does not know it;
does not know. They come in step order, the step that sets the input side `Token moved`, giving the token's address as the `Token In` line does; and
first when it has them: `Moved from the sender`, its amount, worded as the `Moved to`, which reads `The swap step that spends it`. A token the decoder
`Amount` line words one, or `Unknown (could not be decoded)` when the decoder does not know gives the `Moved from the sender` line alone. A step may also
does not know it; `Token moved`, giving the token's address as the `Token In` take from the user's wallet what the decoder did not read: a V4 step, other
line does; and `Moved to`, which reads `The swap step that spends it`. A token than the one that sets the input side, whose input or one of whose `SETTLE`s
the decoder does not know gives the `Moved from the sender` line alone. A step or `SETTLE_ALL`s it cannot read, which the router still runs; a V2 or V3
may also take from the user's wallet what the decoder did not read: a V4 step, exact-in or V2 exact-out step or a `PERMIT2_TRANSFER_FROM` whose input it
other than the one that sets the input side, whose input or one of whose cannot read; and every `PERMIT2_TRANSFER_FROM_BATCH`. Each such step gives one
`SETTLE`s or `SETTLE_ALL`s it cannot read, which the router still runs; a V2 `Moved from the sender` line among those, reading
or V3 exact-in or V2 exact-out step or a `PERMIT2_TRANSFER_FROM` whose input `Unknown (could not be decoded)`. That line, or any line for a token the
it cannot read; every `PERMIT2_TRANSFER_FROM_BATCH`; and, once the input side decoder does not know, leaves no `Amount` line, whose figure might be too low.
is set, every `V3_SWAP_EXACT_OUT`, which the decoder does not read, and every Then each `PERMIT2_TRANSFER_FROM` but one into the router, in step order,
step it fails on. Each such step gives one `Moved from the sender` line among gives three lines: `Moved from the sender`, the amount it moves, for example
those, reading `Unknown (could not be decoded)`. That line, or any line for a `1000000.0000 USDT`; `Token moved`, giving the token's address as the
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
+16 -56
View File
@@ -1169,10 +1169,6 @@ 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;
@@ -1233,11 +1229,10 @@ 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
// exact-in or V2 exact-out step or PERMIT2_TRANSFER_FROM whose input // swap step or PERMIT2_TRANSFER_FROM whose input it cannot read; or a
// it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; and, once the input // PERMIT2_TRANSFER_FROM_BATCH. It is recorded with a null token and
// side is set, a V3_SWAP_EXACT_OUT or a step it fails on. It is // amount: one line says so, and there is no Amount line, whose figure
// recorded with a null token and amount: one line says so, and there // may then be too low.
// 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 });
}; };
@@ -1376,7 +1371,6 @@ 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) {
@@ -1395,14 +1389,11 @@ 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 &&
@@ -1441,7 +1432,6 @@ 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)
@@ -1483,17 +1473,8 @@ 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) {
@@ -1511,7 +1492,6 @@ 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") {
@@ -1530,14 +1510,12 @@ 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]);
@@ -1548,7 +1526,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
} else { } else {
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i);
} }
} else if ( } else if (
command === "TRANSFER" || command === "TRANSFER" ||
@@ -1585,7 +1562,6 @@ 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]);
@@ -1609,7 +1585,6 @@ 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]);
@@ -1640,7 +1615,6 @@ 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]);
@@ -1660,14 +1634,6 @@ 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.
@@ -1735,21 +1701,16 @@ 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: PERMIT2_PERMIT_BATCH and the NFT commands among // read: V3_SWAP_EXACT_OUT and the NFT commands among them,
// them, and a type the release names no command for. // 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 and // Skip commands we can't decode. One may pay any address.
// 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)";
@@ -1770,15 +1731,12 @@ 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 this decoder read every step after // its token or one not known, and no PERMIT2_PERMIT_BATCH comes after
// it in full (see stepsUnread). Permit2 then lets the router take no // it. Permit2 then
// more of the token than that amount, so the permit covers every take // lets the router take no more of the token than that amount, so the
// of it. A later step this decoder did not read may raise what Permit2 // permit covers every take of it. A step this decoder does not read
// lets the router take, as a PERMIT2_PERMIT_BATCH can. A step this // that may take from the sender leaves no figure in any case, and an
// decoder does not read that may take from the sender leaves no // EXECUTE_SUB_PLAN step leaves both sides unknown (below).
// 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],
@@ -1789,7 +1747,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
sameAddress(permit.spender, toAddress) && sameAddress(permit.spender, toAddress) &&
!permit.allowedToFail && !permit.allowedToFail &&
permit.beforeEveryTake && permit.beforeEveryTake &&
!stepsUnread.some((step) => step > permit.step); !stepCommands
.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,70 +1830,6 @@ 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.