1 Commits
Author SHA1 Message Date
clawbot 2c2f497dca 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 Amount line gave 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 each later take of it by a step the user pays. A V4
step, the first included, takes what its SETTLEs paid by the user state.
A take of another token, one after an amount that is not a quantity, or
one of unknown amount gets lines of its own. A step that may take what
the decoder did not read gets one line saying so and leaves no figure.
A PERMIT2_PERMIT names Token In; its amount stands only in the plain
shape of Uniswap's own routes.

Also closes #628.

Model: opus-5-5
2026-10-20 05:01:09 +00:00
3 changed files with 172 additions and 76 deletions
+50 -44
View File
@@ -1235,7 +1235,9 @@ only when no earlier step set it. It finds each side by three rules:
is their sum; in any other case the step gives no `Amount`. The decoder reads
no amount from an exact-out action, and an open delta is not a stated amount.
Its `Min. received` is found the same way, from the `amountOutMin` of the
actions that buy the output currency.
actions that buy the output currency. When the user pays the step any of the
input currency, its `Amount` is what the user pays instead (see Transaction
Decoding).
3. If any swap action in the step could not be read, only `SETTLE`,
`SETTLE_ALL`, `TAKE` and `TAKE_ALL` name its sides, and the step gives no
`Amount` or `Min. received`. A side they did not name is unknown, and the
@@ -3136,42 +3138,46 @@ Currently supported:
token or amount. A V4 step with a swap action sets the input side when no
earlier step set it, even when it names neither (see Display Consistency).
That step counts its own amount, whoever pays it: a V2 exact-out step its
`amountInMax`, and a V4 swap step the `amountIn` of the swap action that
spends its input currency. Each later V2 or V3 exact-in or V2 exact-out step
whose `payerIsUser` is true takes its amount, found in the same way, from the
user's wallet. So does every V4 step but the one that sets the input side,
with a swap action or without: it takes what its `SETTLE`s paid by the user
(`payerIsUser` true) and its `SETTLE_ALL`s pay, for each token on its own. A
`SETTLE` of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of
`OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. A later step
paid from the router spends what the router holds and takes nothing, and so
does a V2 exact-in whose `amountIn` is `ALREADY_PAID`. A later take of
`Token In` is added to the total while both are quantities: a V3 exact-in of
600 USDT and a V2 exact-in of 400 USDT, both paid by the user, read
`1000.0000 USDT`. When the decoder does not know a take's amount, the total is
not known and there is no `Amount` line. `Token In` is the token of the last
`PERMIT2_PERMIT` the decoder reads, when there is one. Its amount, `Unlimited`
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 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. When the step that sets the input side states no amount and
there is no `Amount` line, that step gets them too whenever another take's
`amountInMax`, and a V4 step what it takes of its input token from the user's
wallet, as below, or, when the user pays it none of that token, the `amountIn`
of the swap action that spends it. Each later V2 or V3 exact-in or V2
exact-out step whose `payerIsUser` is true takes its amount, found in the same
way, from the user's wallet. Every V4 step, with a swap action or without,
takes from the user's wallet what its `SETTLE`s paid by the user
(`payerIsUser` true) and its `SETTLE_ALL`s pay, for each token on its own;
what the step that sets the input side takes of any other token counts as a
later step's take. A `SETTLE` of any amount but `OPEN_DELTA` pays that amount,
and a `SETTLE` of `OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as
above. So a V4 step that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by
the user and the rest taken to another address, reads `5000.0000 USDT`. A
later step paid from the router spends what the router holds and takes
nothing, and so does a V2 exact-in whose `amountIn` is `ALREADY_PAID`. A later
take of `Token In` is added to the total while both are quantities: a V3
exact-in of 600 USDT and a V2 exact-in of 400 USDT, both paid by the user,
read `1000.0000 USDT`. When the decoder does not know a take's amount, the
total is not known and there is no `Amount` line. `Token In` is the token of
the last `PERMIT2_PERMIT` the decoder reads, when there is one. Its amount,
`Unlimited` 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 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. When the step that sets the input side states no amount
and there is no `Amount` line, that step gets them too whenever another take's
lines give a figure, so that figure cannot read as all that leaves. 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
@@ -3179,13 +3185,13 @@ Currently supported:
`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`; every `V3_SWAP_EXACT_OUT`,
which the decoder does not read, wherever it stands; every `EXECUTE_SUB_PLAN`,
wherever it stands; and every step it fails on. Each such step gives one
take from the user's wallet what the decoder did not read: a V4 step 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`; every `V3_SWAP_EXACT_OUT`, which the decoder
does not read, wherever it stands; every `EXECUTE_SUB_PLAN`, wherever it
stands; 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,
+38 -28
View File
@@ -903,7 +903,8 @@ function totalAmount(amounts) {
// input side from it only when no earlier step set it. A side it sets that
// these rules leave unknown is set unknown, with no figure. A step with no
// swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL
// name.
// name. When the user pays the step any of Token In (`paidByUser` below),
// decode() takes that, not the Amount of rule 2, as the step's amount.
//
// `takes` is every TAKE, TAKE_ALL and TAKE_PORTION, as the currency it takes
// and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes
@@ -1220,9 +1221,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputEstablished = true;
};
// What each step but the one that sets the input side takes from the
// sender, as { token, amount }, in step order. After the loop each
// joins the Amount line or gets lines of its own (see permitStands).
// What the steps take from the sender beyond the amount of the step
// that sets the input side, as { token, amount }, in step order.
// After the loop each joins the Amount line or gets lines of its own
// (see permitStands).
const userPaid = [];
// A swap step the user pays for (`payerIsUser` true) takes `amount`
// of `token` from the sender. A V2 exact-in of ALREADY_PAID takes
@@ -1231,14 +1233,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (amount !== ALREADY_PAID) userPaid.push({ token, amount });
};
// 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
// exact-in or V2 exact-out step or PERMIT2_TRANSFER_FROM whose input
// it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; a V3_SWAP_EXACT_OUT;
// an EXECUTE_SUB_PLAN; and a step it fails on. It is recorded with a
// null token and amount: one line says so, there is no Amount line,
// whose figure may then be too low, and the amount of a permit after
// it does not stand (see permitStands).
// V4 step whose input 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 it cannot read; a
// PERMIT2_TRANSFER_FROM_BATCH; a V3_SWAP_EXACT_OUT; an
// EXECUTE_SUB_PLAN; and a step it fails on. It is recorded with a null
// token and amount: one line says so, there is no Amount line, whose
// figure may then be too low, and the amount of a permit after it
// does not stand (see permitStands).
const spentUnread = () => {
userPaid.push({ token: null, amount: null });
};
@@ -1677,6 +1679,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
swapUndecoded = true;
commandNames[i] += " (could not be decoded)";
}
// The step's amount, if it sets the input side: what the
// user pays it of its input currency (see
// decodeV4Swap()), or, when the user pays none of it,
// what its swap actions spend of it, whoever pays that.
const paidIn = v4?.paidByUser.find(
(p) => p.currency === v4.tokenIn,
);
const amountIn = paidIn ? paidIn.amount : v4?.amountIn;
if (v4 && v4.hasSwapAction) {
// A step with a swap action always sets the output
// side, as the last swap step wins, and sets the
@@ -1688,7 +1698,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// token, likewise never sits beside an earlier step's
// other half.
if (!inputEstablished) {
setInput(v4.tokenIn, v4.amountIn);
setInput(v4.tokenIn, amountIn);
}
outputToken = v4.tokenOut;
minOutput = v4.amountOutMin;
@@ -1698,26 +1708,26 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names:
// setInputOnce() and setOutput() leave alone a side
// whose two halves are both unstated.
setInputOnce(v4.tokenIn, v4.amountIn);
setInputOnce(v4.tokenIn, amountIn);
setOutput(v4.tokenOut, v4.amountOutMin);
}
// Every V4 step but the one that sets the input side,
// with a swap action or without, takes from the sender
// what the user pays it (an amount an unread action may
// change is null). One whose input, or one of whose
// SETTLEs or SETTLE_ALLs, this decoder could not read may
// take anything: the router still runs it.
// Every V4 step, with a swap action or without, takes
// from the sender what the user pays it (an amount an
// unread action may change is null), less what the step
// that sets the input side counts as its amount. One
// whose input, or one of whose SETTLEs or SETTLE_ALLs,
// this decoder could not read may take anything: the
// router still runs it.
const setInputHere = inputEstablished && !inputSetBefore;
if (!setInputHere) {
if (v4) {
for (const p of v4.paidByUser) {
addUserPaid(p.currency, p.amount);
}
}
if (!v4 || v4.settleUnread) {
spentUnread();
if (v4) {
for (const p of v4.paidByUser) {
if (setInputHere && p === paidIn) continue;
addUserPaid(p.currency, p.amount);
}
}
if (!v4 || v4.settleUnread) {
spentUnread();
}
} else if (command === "UNWRAP_WETH") {
hasUnwrapWeth = true;
const u = decodeUnwrapWeth(inputs[i]);
+84 -4
View File
@@ -1590,6 +1590,61 @@ describe("uniswap decoder", () => {
},
);
// https://git.eeqj.de/sneak/AutistMask/issues/628 — the V4 step that
// sets the input side takes what its SETTLE paid by the user states,
// not what its swap spends. Each of these read 400.0000 USDT before;
// on next the first two read 400.0000 USDT and 1.0000 USDT.
const V4_SETTLE_5000 = encodeV4Swap(
new Uint8Array([
V4_SETTLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ID,
V4_TAKE_ALL_ID,
]),
[
settle(USDT_ADDR, 5000000000n, true),
USDT_FOR_USDC_400,
take(USDT_ADDR, OTHER_ADDR, 0n), // the rest
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
test.each([
{
kind: "a V4 step swapping 400 USDT with a SETTLE of 5,000 USDT by the user, the rest taken to another address,",
commands: [0x10],
inputs: [V4_SETTLE_5000],
},
{
kind: "a PERMIT2_PERMIT of 1 USDT that the router may let fail, then that V4 step,",
commands: [0x8a, 0x10],
inputs: [
encodePermit2(USDT_ADDR, 1000000n, ROUTER_ADDR),
V4_SETTLE_5000,
],
},
// The unread SWEEP keeps the permit's amount from standing.
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router, that V4 step, then a SWEEP that cannot be read,",
commands: [0x0a, 0x10, 0x04],
inputs: [
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
V4_SETTLE_5000,
dirtyWord(
coder.encode(
["address", "address", "uint256"],
[USDC_ADDR, USER_ADDR, 0n],
),
1,
),
],
},
])("$kind shows the 5,000 USDT", ({ commands, inputs }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "5000.0000 USDT"],
]);
});
// A step that may take from the sender what the decoder did not read
// leaves no Amount figure, which may be too low, and one line says so.
const UNREAD_SWAP = V4_SWAP_ACTION_SHAPES[0].newer;
@@ -1644,6 +1699,24 @@ describe("uniswap decoder", () => {
});
test.each([
{
kind: "a V4 step swapping 400 USDT whose SETTLE by the user cannot be read",
commands: [0x10],
inputs: [
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
USDT_FOR_USDC_400,
dirtyWord(settle(USDT_ADDR, USDT_400, true), 0),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
],
},
{
kind: "a V3 step of 600 USDT, then a V4 step whose input cannot be read",
commands: [0x00, 0x10],
@@ -2042,8 +2115,10 @@ describe("uniswap decoder", () => {
first: V4_EXACT_OUT_600,
lines: [...UNKNOWN_USDT, ...UP_TO_400_USDT],
},
// Settled by the user, the step's amount would be what that
// SETTLE pays, which the decoder cannot state here.
{
kind: "a V4 exact-in step of the open delta",
kind: "a V4 exact-in step of the open delta, settled from the router",
commands: [0x10, 0x09],
first: encodeV4Swap(
new Uint8Array([
@@ -2053,7 +2128,7 @@ describe("uniswap decoder", () => {
]),
[
encodeV4ExactInSingle(USDT_ADDR, USDC_ADDR, 0n, 1n),
encodeV4Settle(USDT_ADDR),
settle(USDT_ADDR, 0n, false),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
@@ -2283,7 +2358,9 @@ describe("uniswap decoder", () => {
// (v4-periphery ActionConstants.OPEN_DELTA = 0, applied by V4Router's
// _swapExactInputSingle / _swapExactInput). It is not a quantity, and
// printing "0.0000 WETH" for it would state the exact inverse of what the
// step does. Against c9ebac8 the Amount line was omitted entirely.
// step does. Against c9ebac8 the Amount line was omitted entirely. The
// SETTLE is paid from the router: one paid by the user would make the
// step's amount what that SETTLE pays.
test("a V4 open-delta amountIn is named, not printed as zero", () => {
const data = buildExecute(
"0x10",
@@ -2295,7 +2372,10 @@ describe("uniswap decoder", () => {
V4_TAKE_ID,
]),
[
encodeV4Settle(WETH_ADDR),
coder.encode(
["address", "uint256", "bool"],
[WETH_ADDR, 0n, false],
),
encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,