fix: with no Amount line, the 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. Routes with an Amount line read as before.

Model: opus-5-5
This commit is contained in:
clawbot
2026-10-21 01:28:13 +00:00
parent 5f2b606754
commit 5a444781bc
4 changed files with 124 additions and 20 deletions
+16 -10
View File
@@ -3251,7 +3251,12 @@ Currently supported:
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
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
@@ -3259,15 +3264,16 @@ Currently supported:
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, 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
`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
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;
+13 -1
View File
@@ -2041,6 +2041,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).
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
// whoever pays it. Stating none, it counts only when the user pays it
@@ -2053,6 +2057,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let firstTakeUnstated = null;
if (!permitStands || sameAddress(inputToken, permit.token)) {
total = inputAmount;
if (typeof inputAmount === "bigint") {
inTotal.push({ token: inputToken, amount: inputAmount });
}
if (hasFirstTake && !present(inputAmount)) {
firstTakeUnstated = { token: inputToken, amount: null };
}
@@ -2071,6 +2078,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
typeof t.amount === "bigint"
) {
total += t.amount;
inTotal.push(t);
continue;
}
spentFromSender.push(t);
@@ -2202,7 +2210,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// A null token in `spentFromSender`, a step this decoder did not read
// or a token it does not know, and one in `permits`, a permit or
// batch it could not read, leave no Amount figure, which may then be
// too low.
// too low. With no Amount line, each take the total adds up gets
// lines of its own, first, so that every amount this decoder read is
// shown.
if (
present(inputAmount) &&
spentFromSender.every((s) => present(s.token)) &&
@@ -2214,6 +2224,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
value: amount.display,
rawValue: amount.raw,
});
} else {
spentFromSender.unshift(...inTotal);
}
// What steps take from the sender beyond the Amount line, in the shape
+79 -4
View File
@@ -1447,6 +1447,20 @@ describe("uniswap decoder", () => {
USDT_ADDR,
USDC_ADDR,
]);
// The lines of its 600 USDT when there is no Amount line.
// https://git.eeqj.de/sneak/AutistMask/issues/659 — on next at
// 5f2b606 no line gave the 600 USDT then.
const USDT_600_GROUP = [
["Moved from the sender", "600.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
];
// The same for a step's 400 USDT.
const USDT_400_GROUP = [
["Moved from the sender", "400.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
];
// V4 SETTLE of `amount` of `currency`, and TAKE of `amount` of
// `currency` to `recipient`.
const settle = (currency, amount, payerIsUser) =>
@@ -1673,6 +1687,7 @@ describe("uniswap decoder", () => {
encodeV4SettleAll(USDT_ADDR, USDT_400),
],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
@@ -1693,6 +1708,7 @@ describe("uniswap decoder", () => {
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
@@ -1824,6 +1840,8 @@ describe("uniswap decoder", () => {
// 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.
// The amount of the step that set the input side then gets lines of
// its own.
const UNREAD_SWAP = V4_SWAP_ACTION_SHAPES[0].newer;
test.each([
{
@@ -1871,6 +1889,7 @@ describe("uniswap decoder", () => {
[V3_600, encodeV4Swap(new Uint8Array(actions), params)],
);
expect(linesBetweenTokens(result)).toEqual([
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
]);
});
@@ -1893,6 +1912,7 @@ describe("uniswap decoder", () => {
],
),
],
first: USDT_400_GROUP,
},
{
kind: "a V3 step of 600 USDT, then a V4 step whose input cannot be read",
@@ -1963,9 +1983,27 @@ describe("uniswap decoder", () => {
),
],
},
])("$kind", ({ commands, inputs }) => {
// Each take the total would add up gets lines of its own.
{
kind: "a V3 step of 600 USDT and a V2 step of 400 USDT the user pays for, then a PERMIT2_TRANSFER_FROM_BATCH",
commands: [0x00, 0x08, 0x0d],
inputs: [
V3_600,
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
coder.encode(
["tuple(address,address,uint160,address)[]"],
[[[USER_ADDR, OTHER_ADDR, USDT_400, USDT_ADDR]]],
),
],
first: [...USDT_600_GROUP, ...USDT_400_GROUP],
},
])("$kind", ({ commands, inputs, first = USDT_600_GROUP }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([
...first,
["Moved from the sender", UNDECODED],
]);
});
@@ -1984,6 +2022,7 @@ describe("uniswap decoder", () => {
commands: [0x00, 0x08, 0x21],
inputs: [V3_600, V2_DAI_400, SUB_PLAN],
lines: [
...USDT_600_GROUP,
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"],
@@ -1995,6 +2034,7 @@ describe("uniswap decoder", () => {
commands: [0x21, 0x00, 0x08],
inputs: [SUB_PLAN, V3_600, V2_DAI_400],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
@@ -2013,6 +2053,7 @@ describe("uniswap decoder", () => {
SUB_PLAN,
],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
@@ -2635,10 +2676,35 @@ describe("uniswap decoder", () => {
[input, V3_600],
);
expect(linesBetweenTokens(result)).toEqual([
...USDT_600_GROUP,
["Permit2 allowance", UNDECODED],
]);
},
);
// https://git.eeqj.de/sneak/AutistMask/issues/659 — on next at
// 5f2b606 only the 400 DAI was shown.
test("a PERMIT2_PERMIT that cannot be read, then a V3 exact-in of 600 USDT and a V2 exact-in of 400 DAI, both paid by the user, shows both amounts", () => {
const result = decodeSteps(
[0x0a, 0x00, 0x08],
[
dirtyWord(
encodePermit2(USDT_ADDR, USDT_5000, ROUTER_ADDR),
0,
),
V3_600,
encodeV2SwapExactIn(USER_ADDR, DAI_400, 390000000n, [
DAI_ADDR,
USDC_ADDR,
]),
],
);
expect(linesBetweenTokens(result)).toEqual([
...USDT_600_GROUP,
...DAI_GROUP,
["Permit2 allowance", UNDECODED],
]);
});
});
// A V3_SWAP_EXACT_OUT whose path is shorter than one token cannot be
@@ -2650,7 +2716,12 @@ describe("uniswap decoder", () => {
[USER_ADDR, 390000000n, USDT_400, "0x", true],
);
const result = decodeSteps([0x00, 0x01], [V3_600, unreadable]);
// With a V3 exact-out step, the 600 USDT reads as a maximum, as
// the Amount line would.
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", "Up to 600.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
["Moved from the sender", UNDECODED],
]);
expect(detail(result, "Paid to").value).toBe(UNDECODED);
@@ -2701,6 +2772,9 @@ describe("uniswap decoder", () => {
],
);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", "Up to 0.5000 WETH"],
["Token moved", "WETH (" + WETH_ADDR + ")"],
["Moved to", "The swap step that spends it"],
["Moved from the sender", UNDECODED],
...permitLines("1.0000 WETH", WETH_ADDR, "WETH", ROUTER_ADDR),
]);
@@ -2856,14 +2930,15 @@ describe("uniswap decoder", () => {
);
// The V4 exact-out route above the other way round, where the later
// take's unknown amount leaves no figure at all, and other ways the
// step that sets the input side states no amount.
// take's unknown amount leaves no Amount figure and the 400 USDT gets
// lines of its own, and other ways the step that sets the input side
// states no amount.
test.each([
{
kind: "a V2 exact-out of at most 400 USDT, then a V4 exact-out step of at most 600 USDT",
commands: [0x09, 0x10],
inputs: [V2_EXACT_OUT_400, V4_EXACT_OUT_600],
lines: UNKNOWN_USDT,
lines: [...UP_TO_400_USDT, ...UNKNOWN_USDT],
},
{
kind: "a PERMIT2_PERMIT of 1 USDT to another spender, a V4 exact-out step of at most 600 USDT, then a V2 exact-out of at most 400 USDT",
+16 -5
View File
@@ -1439,8 +1439,17 @@ describe("any token left in the router is not received", () => {
"Token In",
// A later step whose SETTLE or SETTLE_ALL the decoder cannot read
// may take anything from the sender: one line says so, in place
// of the Amount line (https://git.eeqj.de/sneak/AutistMask/issues/625).
unread ? "Moved from the sender" : "Amount",
// of the Amount line (https://git.eeqj.de/sneak/AutistMask/issues/625),
// after the lines the WRAP_ETH's 1 ETH then gets
// (https://git.eeqj.de/sneak/AutistMask/issues/659).
...(unread
? [
"Moved from the sender",
"Token moved",
"Moved to",
"Moved from the sender",
]
: ["Amount"]),
// A later swap step the user pays for spends `spent` of a token
// other than Token In.
...(spent
@@ -1457,9 +1466,11 @@ describe("any token left in the router is not received", () => {
expect(detail(result, "Moved from the sender").value).toBe(spent);
}
if (unread) {
expect(detail(result, "Moved from the sender").value).toBe(
UNDECODED,
);
expect(
result.details
.filter((d) => d.label === "Moved from the sender")
.map((d) => d.value),
).toEqual(["1.0000 ETH", UNDECODED]);
}
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Token moved into the router")).toEqual(left);