fix: the swap decoder shows the ETH of a WRAP_ETH that does not set the input side (closes #721) #725

Merged
clawbot merged 1 commits from issue-721-wrap-eth-moved-from-sender into next 2026-11-03 06:51:15 +01:00
4 changed files with 226 additions and 17 deletions
+8 -3
View File
@@ -1365,9 +1365,14 @@ when both tokens are known. The lines are:
the sender that the `Amount` line does not include. The same three lines show
what a `PERMIT2_TRANSFER_FROM` moves and where. A `WRAP_ETH` can pay its WETH
to a recipient other than the router. When its amount gets these lines, its
`Moved to` line names that recipient, or reads `The sender`. ETH that the
sender pays a V4 step can get these three lines when the router may hold ETH
from an earlier step. When the decoder knows the amount, its
`Moved to` line names that recipient, or reads `The sender`. A `WRAP_ETH`
after the step that sets `Token In` takes the amount it states from the
sender. When it pays anyone but the router, that amount gets these lines. When
it pays the router, that amount joins the `Amount` line if `Token In` is ETH,
and gets these lines if not. A later `WRAP_ETH` of `CONTRACT_BALANCE` states
no amount, so it gets no lines. ETH that the sender pays a V4 step or a
`WRAP_ETH` of a stated amount can get these three lines when the router may
hold ETH from an earlier step. When the decoder knows the amount, its
`Moved from the sender` line reads `Up to` and the amount.
- `Permit2 allowance`, `Allowance token` and `Allowance spender`: each allowance
that a `PERMIT2_PERMIT` or `PERMIT2_PERMIT_BATCH` grants, unless the permit's
+30 -7
View File
@@ -1368,7 +1368,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// After the loop each joins the Amount line or gets lines of its own
// (see permitStands). A take with `atMost` set may take less than
// `amount`: it never joins the Amount line, and its line reads
// "Up to" and the amount.
// "Up to" and the amount. A take with a `recipient`, the ETH of a
// later WRAP_ETH that pays anyone but the router, never joins the
// Amount line either: its Moved to line names that recipient.
const userPaid = [];
// A swap step the user pays for (`payerIsUser` true) takes `amount`
// of `token` from the sender, or at most that when `atMost` is set. A
@@ -1380,8 +1382,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
};
// 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
// cannot read; a V2 or V3 swap step or PERMIT2_TRANSFER_FROM whose
// input it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; an
// cannot read; a V2 or V3 swap step, PERMIT2_TRANSFER_FROM or WRAP_ETH
// whose input it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; 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
@@ -1907,6 +1909,25 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!isRouter(w.recipient)) {
wrapEthRecipient = w.recipient;
}
} else if (
w.amount > 0n &&
w.amount !== CONTRACT_BALANCE
) {
// The ETH of a stated amount comes from the
// transaction's value. ETH an earlier step left in
// the router may pay part of it, and the sender
// then pays at most that amount. CONTRACT_BALANCE
// states no amount to take.
userPaid.push({
token: "0x0000000000000000000000000000000000000000",
amount: w.amount,
atMost: heldByRouter.has(
"0x0000000000000000000000000000000000000000",
),
recipient: isRouter(w.recipient)
? null
: w.recipient,
});
}
addPayee(w.recipient);
// CONTRACT_BALANCE wraps all the router's ETH.
@@ -1931,6 +1952,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else {
payeeUnread = true;
stepsUnread.push(i);
spentUnread();
}
} else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]);
@@ -2203,8 +2225,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// added to it while both are quantities. A take that cannot be added,
// being of another token, coming after an amount that is not a
// quantity (a V4 open delta, ALREADY_PAID, or none stated), or having
// `atMost` set, gets lines of its own. One whose amount is not known
// also leaves the total unknown, with no figure.
// `atMost` or a `recipient` set, gets lines of its own. One whose
// amount is not known also leaves the total unknown, with no figure.
//
// A PERMIT2_PERMIT names Token In only when its amount stands as the
// Amount figure in place of the total, which it does only in the
@@ -2245,7 +2267,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// { token, amount, atMost, recipient }. A null token is a step this
// decoder did not read (see spentUnread()), or a token it does not
// know. `atMost` is as in `userPaid`. Only the take of a WRAP_ETH that
// pays elsewhere has a recipient (see wrapEthRecipient).
// pays elsewhere has a recipient (see wrapEthRecipient and userPaid).
const spentFromSender = [];
let total = null;
// The takes from the sender that `total` adds up, each a quantity, in
@@ -2292,7 +2314,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
ofTokenIn &&
typeof total === "bigint" &&
typeof t.amount === "bigint" &&
!t.atMost
!t.atMost &&
!present(t.recipient)
) {
total += t.amount;
inTotal.push(t);
+170
View File
@@ -2593,6 +2593,9 @@ describe("uniswap decoder", () => {
],
lines: [
["Amount", "1.0000 ETH"],
["Moved from the sender", "Up to 1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", OTHER_ADDR],
...ethLines("Up to 1.0000 ETH"),
],
},
@@ -2622,6 +2625,7 @@ describe("uniswap decoder", () => {
],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The swap step that spends it"),
...ethLines("Up to 1.0000 ETH"),
],
},
@@ -2753,6 +2757,172 @@ describe("uniswap decoder", () => {
expect(linesBetweenTokens(result)).toEqual(lines);
});
// https://git.eeqj.de/sneak/AutistMask/issues/721 — a WRAP_ETH that
// does not set the input side takes the amount it states from the
// sender. On next no line gave that ETH. The rows marked "reads as on
// next" state no amount to take from the sender. Uniswap's own routes
// wrap CONTRACT_BALANCE after their first step, as the last two rows
// do.
const CONTRACT_BALANCE = 2n ** 255n;
const WRAP_TO_OTHER = encodeWrapEth(OTHER_ADDR, ONE_ETH);
test.each([
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to another address",
commands: [0x00, 0x0b],
inputs: [V3_600, WRAP_TO_OTHER],
lines: [["Amount", "600.0000 USDT"], ...wrapLines(OTHER_ADDR)],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to address(1)",
commands: [0x00, 0x0b],
inputs: [V3_600, encodeWrapEth(MSG_SENDER, ONE_ETH)],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The sender"),
],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to address(2)",
commands: [0x00, 0x0b],
inputs: [V3_600, WRAP_INTO_ROUTER],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The swap step that spends it"),
],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to the router by its own address",
commands: [0x00, 0x0b],
inputs: [V3_600, encodeWrapEth(ROUTER_ADDR, ONE_ETH)],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The swap step that spends it"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a WRAP_ETH of 1 ETH to another address",
commands: [0x0b, 0x0b],
inputs: [WRAP_INTO_ROUTER, WRAP_TO_OTHER],
lines: [["Amount", "1.0000 ETH"], ...wrapLines(OTHER_ADDR)],
},
{
kind: "two WRAP_ETHs of 1 ETH into the router, which the Amount line adds up",
commands: [0x0b, 0x0b],
inputs: [WRAP_INTO_ROUTER, WRAP_INTO_ROUTER],
lines: [["Amount", "2.0000 ETH"]],
},
{
kind: "a V3 exact-in of 600 USDT for WETH paid to the router, an UNWRAP_WETH into the router, then a WRAP_ETH of 1 ETH to another address",
commands: [0x00, 0x0c, 0x0b],
inputs: [
encodeV3SwapExactIn(ADDRESS_THIS, USDT_600, 1n, [
USDT_ADDR,
WETH_ADDR,
]),
UNWRAP_INTO_ROUTER,
WRAP_TO_OTHER,
],
lines: [
["Amount", "600.0000 USDT"],
["Moved from the sender", "Up to 1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", OTHER_ADDR],
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a WRAP_ETH of 1 ETH into the router",
commands: [0x0b, 0x0c, 0x0b],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
WRAP_INTO_ROUTER,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router, a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to another address",
commands: [0x0a, 0x00, 0x0b],
inputs: [
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
V3_600,
WRAP_TO_OTHER,
],
lines: [["Amount", "5000.0000 USDT"], ...wrapLines(OTHER_ADDR)],
},
{
kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH to another address, then an EXECUTE_SUB_PLAN",
commands: [0x00, 0x0b, 0x21],
inputs: [V3_600, WRAP_TO_OTHER, SUB_PLAN],
lines: [
...USDT_600_GROUP,
...wrapLines(OTHER_ADDR),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH whose input this decoder cannot read",
commands: [0x00, 0x0b],
inputs: [V3_600, "0x1234"],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of nothing to another address, reads as on next",
commands: [0x00, 0x0b],
inputs: [V3_600, encodeWrapEth(OTHER_ADDR, 0n)],
lines: [["Amount", "600.0000 USDT"]],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of CONTRACT_BALANCE to the sender, reads as on next",
commands: [0x00, 0x0b],
inputs: [V3_600, encodeWrapEth(MSG_SENDER, CONTRACT_BALANCE)],
lines: [["Amount", "600.0000 USDT"]],
},
{
kind: "a V4 swap of 600 USDT that takes ETH into the router, a WRAP_ETH of CONTRACT_BALANCE into the router, then a V3 exact-in of it, reads as on next",
commands: [0x10, 0x0b, 0x00],
inputs: [
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
ZERO_ADDR,
USDT_600,
1n,
),
settle(USDT_ADDR, 0n, true),
take(ZERO_ADDR, ADDRESS_THIS, 0n),
],
),
encodeWrapEth(ADDRESS_THIS, CONTRACT_BALANCE),
coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
USER_ADDR,
CONTRACT_BALANCE,
1n,
encodeV3Path([WETH_ADDR, USDC_ADDR]),
false,
],
),
],
lines: [["Amount", "600.0000 USDT"]],
},
])("$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) =>
+18 -7
View File
@@ -370,7 +370,8 @@ const PAID_ELSEWHERE = [
"Steps",
"Deadline",
];
// `lines` with the lines of one PERMIT2_TRANSFER_FROM after Amount.
// `lines` with the lines of one PERMIT2_TRANSFER_FROM or later WRAP_ETH after
// Amount.
const withMove = (lines) =>
lines.flatMap((l) =>
l === "Amount"
@@ -985,7 +986,7 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
);
// The SWEEP pays the user the swap's WETH, so the UNWRAP_WETH unwraps the
// WRAP_ETH's.
// WRAP_ETH's. The WRAP_ETH's 1 ETH gets lines of its own.
test("a SWEEP of the swap's WETH to the user, then a WRAP_ETH into the router, reads as ETH with no minimum", () => {
const result = decode(
execute(
@@ -996,7 +997,10 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
expect(labels(result)).toEqual(withMove(NO_MINIMUM_TO_USER));
expect(detail(result, "Moved from the sender").value).toBe(
"1.0000 ETH",
);
});
test("an UNWRAP_WETH into the router, then a WRAP_ETH of all its ETH to the user, reads as WETH with no minimum", () => {
@@ -1097,7 +1101,9 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
},
);
// The user receives 0.5 WETH and the rest of the swap's output as ETH.
// The user receives 0.5 WETH and the rest of the swap's output as ETH. The
// router holds ETH when the WRAP_ETH runs, so the sender pays at most its
// 0.5 ETH.
test("an UNWRAP_WETH into the router, then a WRAP_ETH of 0.5 ETH to the user, reads as WETH with no minimum", () => {
const result = decode(
execute(
@@ -1108,7 +1114,11 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
expect(labels(result)).toEqual(withMove(NO_MINIMUM_TO_USER));
expect(detail(result, "Moved from the sender").value).toBe(
"Up to 0.5000 ETH",
);
expect(detail(result, "Moved to").value).toBe("The sender");
});
// The UNWRAP_WETH pays the user the swap's WETH as ETH; the WRAP_ETH wraps
@@ -1348,6 +1358,7 @@ describe("any token left in the router is not received", () => {
),
),
left: tokenLine(WETH, "WETH"),
spent: "1.0000 ETH",
},
// A SETTLE of OPEN_DELTA from the router pays only what the step
// still owes of its currency when it runs.
@@ -1516,8 +1527,8 @@ describe("any token left in the router is not received", () => {
"Moved from the sender",
]
: ["Amount"]),
// A later swap step the user pays for spends `spent` of a token
// other than Token In.
// A later swap step or WRAP_ETH the user pays for spends `spent`
// of a token other than Token In.
...(spent
? ["Moved from the sender", "Token moved", "Moved to"]
: []),