1 Commits
Author SHA1 Message Date
clawbot 64a77b58e8 fix: with no Amount line, the user's takes the total adds up get lines of their own (closes #659)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
When a step this decoder cannot read, an unreadable permit, or a take
of Token In with an unknown amount held back the Amount line, the
amount the step that sets the input side states, and each later take
of Token In added to it, appeared on no line, while other takes kept
their own Moved from the sender lines. Those takes now get the same
lines, first. The first step's amount gets them only when the user
pays that step (a V2 or V3 step with payerIsUser true, a V4 step the
user pays some of its input token, or a WRAP_ETH): one paid from the
router takes nothing from the wallet. Routes with an Amount line read
as before.

Model: opus-5-5
2026-10-21 02:05:32 +00:00
3 changed files with 141 additions and 31 deletions
+24 -20
View File
@@ -3254,26 +3254,30 @@ Currently supported:
whose amount the decoder does not know. When there is no `Amount` line, each
take the total counts gets them too, so that every amount the decoder reads is
shown: the amount the step that sets the input side states, when it is a
quantity, and each later take of `Token In` added to it. A `PERMIT2_PERMIT`
the decoder cannot read, then V3 exact-ins of 600 USDT and 400 DAI paid by the
user, give lines for both amounts. When the step that sets the input side
states no amount, the user pays it some of its input token, and there is no
`Amount` line, that step gets them too whenever another take's lines give a
figure, or a `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines
below or, into the router, on its `Paid to` line, so that figure cannot read
as all that leaves. When it states no amount and the user pays it none of its
input token, as when the router pays it, that step takes none of that token
from the user's wallet and never gets them, with a permit or without. They
come in step order, except that the step that sets the input side comes first
when it has them, followed, when there is no `Amount` line, by the other takes
the total counts: `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
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 exact-out step or a
quantity and the user pays that step (a V2 or V3 step whose `payerIsUser` is
true, a V4 step the user pays some of its input token, or a `WRAP_ETH`, whose
ETH comes from the transaction's value), and each later take of `Token In`
added to it. A step that sets the input side and is paid from the router takes
nothing from the user's wallet and gets none. A `PERMIT2_PERMIT` the decoder
cannot read, then V3 exact-ins of 600 USDT and 400 DAI paid by the user, give
lines for both amounts. When the step that sets the input side states no
amount, the user pays it some of its input token, and there is no `Amount`
line, that step gets them too whenever another take's lines give a figure, or
a `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines below or, into
the router, on its `Paid to` line, so that figure cannot read as all that
leaves. When it states no amount and the user pays it none of its input token,
as when the router pays it, that step takes none of that token from the user's
wallet and never gets them, with a permit or without. They come in step order,
except that the step that sets the input side comes first when it has them,
followed, when there is no `Amount` line, by the other takes the total counts:
`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 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 exact-out step or a
`PERMIT2_TRANSFER_FROM` whose input it cannot read, such as a V3 step whose
path is shorter than one token address or has bytes after its last hop; every
`PERMIT2_TRANSFER_FROM_BATCH`; every `EXECUTE_SUB_PLAN`, wherever it stands;
+20 -11
View File
@@ -1234,9 +1234,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let inputToken = null;
let inputAmount = null;
let inputEstablished = false;
// Whether the user pays the V4 step that set the input side any of
// its input currency (see decodeV4Swap()). Only a V4 step can set the
// input side with no amount, the one case in which this is read.
// Whether the user pays the step that set the input side: a V2 or V3
// swap step with `payerIsUser` true, a V4 step the user pays some of
// its input currency (see decodeV4Swap()), or a WRAP_ETH, whose ETH
// comes from the transaction's value. A step paid from the router
// takes nothing from the sender.
let inputPaidByUser = false;
// The last PERMIT2_PERMIT this decoder read before a step set the
// input side, or null (see permitStands after the loop).
@@ -1622,6 +1624,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountIn);
inputPaidByUser = s.payerIsUser;
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
@@ -1645,6 +1648,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountIn);
inputPaidByUser = s.payerIsUser;
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
@@ -1676,6 +1680,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountInMax);
inputPaidByUser = s.payerIsUser;
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountInMax);
}
@@ -1771,10 +1776,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "WRAP_ETH") {
const w = decodeWrapEth(inputs[i]);
if (w) {
setInputOnce(
"0x0000000000000000000000000000000000000000",
w.amount,
);
if (!inputEstablished) {
setInputOnce(
"0x0000000000000000000000000000000000000000",
w.amount,
);
inputPaidByUser = true;
}
addPayee(w.recipient);
// CONTRACT_BALANCE wraps all the router's ETH.
if (w.amount === CONTRACT_BALANCE) {
@@ -2041,9 +2049,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// (see spentUnread()), or a token it does not know.
const spentFromSender = [];
let total = null;
// The takes `total` adds up, each a quantity, as { token, amount }.
// When there is no Amount line, each gets lines of its own (see
// below).
// The takes from the sender that `total` adds up, each a quantity, as
// { token, amount }. When there is no Amount line, each gets lines of
// its own (see below). The amount of a step that set the input side
// and is paid from the router counts in `total` but is not among them.
const inTotal = [];
// Whether the step that set the input side counts as a take from the
// sender, which may get lines of its own. The amount it stated counts
@@ -2057,7 +2066,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let firstTakeUnstated = null;
if (!permitStands || sameAddress(inputToken, permit.token)) {
total = inputAmount;
if (typeof inputAmount === "bigint") {
if (typeof inputAmount === "bigint" && inputPaidByUser) {
inTotal.push({ token: inputToken, amount: inputAmount });
}
if (hasFirstTake && !present(inputAmount)) {
+97
View File
@@ -2331,6 +2331,103 @@ describe("uniswap decoder", () => {
expect(linesBetweenTokens(result)).toEqual(lines);
});
// A step that sets the input side and is paid from the router takes
// nothing from the sender, so with no Amount line its amount gets no
// lines of its own: in each route below the sender's 600 USDT moves
// into the router first, and such lines would say it left twice.
const INTO_ROUTER_600 = permit2TransferFrom(
USDT_ADDR,
ROUTER_ADDR,
USDT_600,
);
const V3_600_FROM_ROUTER = coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
USER_ADDR,
USDT_600,
590000000n,
encodeV3Path([USDT_ADDR, USDC_ADDR]),
false,
],
);
const TRANSFER_FROM_BATCH = coder.encode(
["tuple(address,address,uint160,address)[]"],
[[[USER_ADDR, OTHER_ADDR, USDT_400, USDT_ADDR]]],
);
test.each([
{
kind: "a PERMIT2_TRANSFER_FROM of 600 USDT into the router, a V3 exact-in of 600 USDT paid from the router, then a PERMIT2_TRANSFER_FROM_BATCH",
commands: [0x02, 0x00, 0x0d],
inputs: [
INTO_ROUTER_600,
V3_600_FROM_ROUTER,
TRANSFER_FROM_BATCH,
],
lines: [["Moved from the sender", UNDECODED]],
},
{
kind: "a PERMIT2_PERMIT that cannot be read, a PERMIT2_TRANSFER_FROM of 600 USDT into the router, then a V3 exact-in of 600 USDT paid from the router",
commands: [0x0a, 0x02, 0x00],
inputs: [
dirtyWord(
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
0,
),
INTO_ROUTER_600,
V3_600_FROM_ROUTER,
],
lines: [["Permit2 allowance", UNDECODED]],
},
{
kind: "a PERMIT2_TRANSFER_FROM of 600 USDT into the router, a V4 step swapping 600 USDT whose SETTLE the router pays, then a PERMIT2_TRANSFER_FROM_BATCH",
commands: [0x02, 0x10, 0x0d],
inputs: [
INTO_ROUTER_600,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
USDT_600,
590000000n,
),
settle(USDT_ADDR, USDT_600, false),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
TRANSFER_FROM_BATCH,
],
lines: [["Moved from the sender", UNDECODED]],
},
{
kind: "a PERMIT2_TRANSFER_FROM of 600 USDT into the router, a V2 exact-in of CONTRACT_BALANCE paid from the router, then an EXECUTE_SUB_PLAN",
commands: [0x02, 0x08, 0x21],
inputs: [
INTO_ROUTER_600,
coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[
USER_ADDR,
2n ** 255n, // CONTRACT_BALANCE
590000000n,
[USDT_ADDR, USDC_ADDR],
false,
],
),
SUB_PLAN,
],
lines: [["Moved from the sender", UNDECODED]],
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax`
// USDT for USDC.
const v3ExactOut = (amountInMax) =>