1 Commits
Author SHA1 Message Date
clawbot cad2d262ce 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 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 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, such as a V3_SWAP_EXACT_OUT or an
EXECUTE_SUB_PLAN, 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.

Model: opus-5-5
2026-10-20 04:15:32 +00:00
3 changed files with 76 additions and 172 deletions
+44 -50
View File
@@ -1235,9 +1235,7 @@ 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 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. 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 Its `Min. received` is found the same way, from the `amountOutMin` of the
actions that buy the output currency. When the user pays the step any of the actions that buy the output currency.
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`, 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 `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 `Amount` or `Min. received`. A side they did not name is unknown, and the
@@ -3138,46 +3136,42 @@ Currently supported:
token or amount. A V4 step with a swap action sets the input side when no 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). 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 That step counts its own amount, whoever pays it: a V2 exact-out step its
`amountInMax`, and a V4 step what it takes of its input token from the user's `amountInMax`, and a V4 swap step the `amountIn` of the swap action that
wallet, as below, or, when the user pays it none of that token, the `amountIn` spends its input currency. Each later V2 or V3 exact-in or V2 exact-out step
of the swap action that spends it. Each later V2 or V3 exact-in or V2 whose `payerIsUser` is true takes its amount, found in the same way, from the
exact-out step whose `payerIsUser` is true takes its amount, found in the same user's wallet. So does every V4 step but the one that sets the input side,
way, from the user's wallet. Every V4 step, with a swap action or without, with a swap action or without: it takes what its `SETTLE`s paid by the user
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. A
(`payerIsUser` true) and its `SETTLE_ALL`s pay, for each token on its own; `SETTLE` of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of
what the step that sets the input side takes of any other token counts as a `OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. A later step
later step's take. A `SETTLE` of any amount but `OPEN_DELTA` pays that amount, paid from the router spends what the router holds and takes nothing, and so
and a `SETTLE` of `OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as does a V2 exact-in whose `amountIn` is `ALREADY_PAID`. A later take of
above. So a V4 step that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by `Token In` is added to the total while both are quantities: a V3 exact-in of
the user and the rest taken to another address, reads `5000.0000 USDT`. A 600 USDT and a V2 exact-in of 400 USDT, both paid by the user, read
later step paid from the router spends what the router holds and takes `1000.0000 USDT`. When the decoder does not know a take's amount, the total is
nothing, and so does a V2 exact-in whose `amountIn` is `ALREADY_PAID`. A later not known and there is no `Amount` line. `Token In` is the token of the last
take of `Token In` is added to the total while both are quantities: a V3 `PERMIT2_PERMIT` the decoder reads, when there is one. Its amount, `Unlimited`
exact-in of 600 USDT and a V2 exact-in of 400 USDT, both paid by the user, included, stands as the `Amount` figure in place of the total only in the
read `1000.0000 USDT`. When the decoder does not know a take's amount, the plain shape of Uniswap's own routes: it is the only `PERMIT2_PERMIT`, its
total is not known and there is no `Amount` line. `Token In` is the token of spender is the router, the router may not let it fail, no step before it takes
the last `PERMIT2_PERMIT` the decoder reads, when there is one. Its amount, its token or a token the decoder does not know, and the decoder reads every
`Unlimited` included, stands as the `Amount` figure in place of the total only step after it in full. Permit2 then lets the router take no more of the token
in the plain shape of Uniswap's own routes: it is the only `PERMIT2_PERMIT`, than that amount, so the permit covers every take of it. A step the decoder
its spender is the router, the router may not let it fail, no step before it does not read in full may raise what Permit2 lets the router take, as a
takes its token or a token the decoder does not know, and the decoder reads `PERMIT2_PERMIT_BATCH` can: a command the decoder does not read (listed at the
every step after it in full. Permit2 then lets the router take no more of the end of this section), a step whose input it cannot read, in whole or in part,
token than that amount, so the permit covers every take of it. A step the and a step it fails on. In any other case the line gives the total, as with no
decoder does not read in full may raise what Permit2 lets the router take, as permit. When the step that sets the input side spends a token other than the
a `PERMIT2_PERMIT_BATCH` can: a command the decoder does not read (listed at permit's, or one the decoder does not know, its amount gets lines of its own
the end of this section), a step whose input it cannot read, in whole or in and the total has no figure to start from: unless the permit's amount stands
part, and a step it fails on. In any other case the line gives the total, as there is no `Amount` line, and each take of the permit's token gets lines of
with no permit. When the step that sets the input side spends a token other its own. Each take the total does not count gives three lines after the
than the permit's, or one the decoder does not know, its amount gets lines of `Amount` line (after `Token In` when there is none): one of a token other than
its own and the total has no figure to start from: unless the permit's amount `Token In`; one of `Token In` after an amount that is not a quantity (a V4
stands there is no `Amount` line, and each take of the permit's token gets open delta, `ALREADY_PAID`, which keep their wording on the `Amount` line, or
lines of its own. Each take the total does not count gives three lines after no stated amount, as of a V4 exact-out step); and one whose amount the decoder
the `Amount` line (after `Token In` when there is none): one of a token other does not know. When the step that sets the input side states no amount and
than `Token In`; one of `Token In` after an amount that is not a quantity (a there is no `Amount` line, that step gets them too whenever another take's
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 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: 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 `Moved from the sender`, its amount, worded as the `Amount` line words one, or
@@ -3185,13 +3179,13 @@ Currently supported:
`Token moved`, giving the token's address as the `Token In` line does; and `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 `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 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 whose take from the user's wallet what the decoder did not read: a V4 step, other
input or one of whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the than the one that sets the input side, whose input or one of whose `SETTLE`s
router still runs; a V2 or V3 exact-in or V2 exact-out step or a or `SETTLE_ALL`s it cannot read, which the router still runs; a V2 or V3
`PERMIT2_TRANSFER_FROM` whose input it cannot read; every exact-in or V2 exact-out step or a `PERMIT2_TRANSFER_FROM` whose input it
`PERMIT2_TRANSFER_FROM_BATCH`; every `V3_SWAP_EXACT_OUT`, which the decoder cannot read; every `PERMIT2_TRANSFER_FROM_BATCH`; every `V3_SWAP_EXACT_OUT`,
does not read, wherever it stands; every `EXECUTE_SUB_PLAN`, wherever it which the decoder does not read, wherever it stands; every `EXECUTE_SUB_PLAN`,
stands; and every step it fails on. Each such step gives one wherever it stands; and every step it fails on. Each such step gives one
`Moved from the sender` line among those, reading `Moved from the sender` line among those, reading
`Unknown (could not be decoded)`. That line, or any line for a token the `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, decoder does not know, leaves no `Amount` line, whose figure might be too low,
+28 -38
View File
@@ -903,8 +903,7 @@ function totalAmount(amounts) {
// input side from it only when no earlier step set it. A side it sets that // 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 // 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 // swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL
// name. When the user pays the step any of Token In (`paidByUser` below), // name.
// 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 // `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 // and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes
@@ -1221,10 +1220,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputEstablished = true; outputEstablished = true;
}; };
// What the steps take from the sender beyond the amount of the step // What each step but the one that sets the input side takes from the
// that sets the input side, as { token, amount }, in step order. // sender, as { token, amount }, in step order. After the loop each
// After the loop each joins the Amount line or gets lines of its own // joins the Amount line or gets lines of its own (see permitStands).
// (see permitStands).
const userPaid = []; const userPaid = [];
// A swap step the user pays for (`payerIsUser` true) takes `amount` // A swap step the user pays for (`payerIsUser` true) takes `amount`
// of `token` from the sender. A V2 exact-in of ALREADY_PAID takes // of `token` from the sender. A V2 exact-in of ALREADY_PAID takes
@@ -1233,14 +1231,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (amount !== ALREADY_PAID) userPaid.push({ token, amount }); if (amount !== ALREADY_PAID) userPaid.push({ token, amount });
}; };
// 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 whose input or one of whose SETTLEs or SETTLE_ALLs it // V4 step, other than the one that sets the input side, whose input
// cannot read; a V2 or V3 exact-in or V2 exact-out step or // or one of whose SETTLEs or SETTLE_ALLs it cannot read; a V2 or V3
// PERMIT2_TRANSFER_FROM whose input it cannot read; a // exact-in or V2 exact-out step or PERMIT2_TRANSFER_FROM whose input
// PERMIT2_TRANSFER_FROM_BATCH; a V3_SWAP_EXACT_OUT; an // it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; a V3_SWAP_EXACT_OUT;
// EXECUTE_SUB_PLAN; and a step it fails on. It is recorded with a null // an EXECUTE_SUB_PLAN; and a step it fails on. It is recorded with a
// token and amount: one line says so, there is no Amount line, whose // null token and amount: one line says so, there is no Amount line,
// figure may then be too low, and the amount of a permit after it // whose figure may then be too low, and the amount of a permit after
// does not stand (see permitStands). // it does not stand (see permitStands).
const spentUnread = () => { const spentUnread = () => {
userPaid.push({ token: null, amount: null }); userPaid.push({ token: null, amount: null });
}; };
@@ -1679,14 +1677,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
swapUndecoded = true; swapUndecoded = true;
commandNames[i] += " (could not be decoded)"; 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) { if (v4 && v4.hasSwapAction) {
// A step with a swap action always sets the output // A step with a swap action always sets the output
// side, as the last swap step wins, and sets the // side, as the last swap step wins, and sets the
@@ -1698,7 +1688,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// token, likewise never sits beside an earlier step's // token, likewise never sits beside an earlier step's
// other half. // other half.
if (!inputEstablished) { if (!inputEstablished) {
setInput(v4.tokenIn, amountIn); setInput(v4.tokenIn, v4.amountIn);
} }
outputToken = v4.tokenOut; outputToken = v4.tokenOut;
minOutput = v4.amountOutMin; minOutput = v4.amountOutMin;
@@ -1708,25 +1698,25 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names: // SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names:
// setInputOnce() and setOutput() leave alone a side // setInputOnce() and setOutput() leave alone a side
// whose two halves are both unstated. // whose two halves are both unstated.
setInputOnce(v4.tokenIn, amountIn); setInputOnce(v4.tokenIn, v4.amountIn);
setOutput(v4.tokenOut, v4.amountOutMin); setOutput(v4.tokenOut, v4.amountOutMin);
} }
// Every V4 step, with a swap action or without, takes // Every V4 step but the one that sets the input side,
// from the sender what the user pays it (an amount an // with a swap action or without, takes from the sender
// unread action may change is null), less what the step // what the user pays it (an amount an unread action may
// that sets the input side counts as its amount. One // change is null). One whose input, or one of whose
// whose input, or one of whose SETTLEs or SETTLE_ALLs, // SETTLEs or SETTLE_ALLs, this decoder could not read may
// this decoder could not read may take anything: the // take anything: the router still runs it.
// router still runs it.
const setInputHere = inputEstablished && !inputSetBefore; const setInputHere = inputEstablished && !inputSetBefore;
if (v4) { if (!setInputHere) {
for (const p of v4.paidByUser) { if (v4) {
if (setInputHere && p === paidIn) continue; for (const p of v4.paidByUser) {
addUserPaid(p.currency, p.amount); addUserPaid(p.currency, p.amount);
}
}
if (!v4 || v4.settleUnread) {
spentUnread();
} }
}
if (!v4 || v4.settleUnread) {
spentUnread();
} }
} else if (command === "UNWRAP_WETH") { } else if (command === "UNWRAP_WETH") {
hasUnwrapWeth = true; hasUnwrapWeth = true;
+4 -84
View File
@@ -1590,61 +1590,6 @@ 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 // 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. // leaves no Amount figure, which may be too low, and one line says so.
const UNREAD_SWAP = V4_SWAP_ACTION_SHAPES[0].newer; const UNREAD_SWAP = V4_SWAP_ACTION_SHAPES[0].newer;
@@ -1699,24 +1644,6 @@ describe("uniswap decoder", () => {
}); });
test.each([ 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", kind: "a V3 step of 600 USDT, then a V4 step whose input cannot be read",
commands: [0x00, 0x10], commands: [0x00, 0x10],
@@ -2115,10 +2042,8 @@ describe("uniswap decoder", () => {
first: V4_EXACT_OUT_600, first: V4_EXACT_OUT_600,
lines: [...UNKNOWN_USDT, ...UP_TO_400_USDT], 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, settled from the router", kind: "a V4 exact-in step of the open delta",
commands: [0x10, 0x09], commands: [0x10, 0x09],
first: encodeV4Swap( first: encodeV4Swap(
new Uint8Array([ new Uint8Array([
@@ -2128,7 +2053,7 @@ describe("uniswap decoder", () => {
]), ]),
[ [
encodeV4ExactInSingle(USDT_ADDR, USDC_ADDR, 0n, 1n), encodeV4ExactInSingle(USDT_ADDR, USDC_ADDR, 0n, 1n),
settle(USDT_ADDR, 0n, false), encodeV4Settle(USDT_ADDR),
encodeV4TakeAll(USDC_ADDR, 0n), encodeV4TakeAll(USDC_ADDR, 0n),
], ],
), ),
@@ -2358,9 +2283,7 @@ describe("uniswap decoder", () => {
// (v4-periphery ActionConstants.OPEN_DELTA = 0, applied by V4Router's // (v4-periphery ActionConstants.OPEN_DELTA = 0, applied by V4Router's
// _swapExactInputSingle / _swapExactInput). It is not a quantity, and // _swapExactInputSingle / _swapExactInput). It is not a quantity, and
// printing "0.0000 WETH" for it would state the exact inverse of what the // printing "0.0000 WETH" for it would state the exact inverse of what the
// step does. Against c9ebac8 the Amount line was omitted entirely. The // step does. Against c9ebac8 the Amount line was omitted entirely.
// 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", () => { test("a V4 open-delta amountIn is named, not printed as zero", () => {
const data = buildExecute( const data = buildExecute(
"0x10", "0x10",
@@ -2372,10 +2295,7 @@ describe("uniswap decoder", () => {
V4_TAKE_ID, V4_TAKE_ID,
]), ]),
[ [
coder.encode( encodeV4Settle(WETH_ADDR),
["address", "uint256", "bool"],
[WETH_ADDR, 0n, false],
),
encodeV4ExactInSingle( encodeV4ExactInSingle(
WETH_ADDR, WETH_ADDR,
USDC_ADDR, USDC_ADDR,