1 Commits
Author SHA1 Message Date
sneak f4f2c549f5 fix: a V4 step of chained swap actions shows what the user receives (closes #563)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A V4 step's input side is now the currency its SETTLE or SETTLE_ALL
names, and its output side the currency its TAKE or TAKE_ALL names. A
side none of them names comes from the swap actions, put in the order
the currencies pass through them: each exact-in action after the ones
before it, each exact-out action in front of them, since the router runs
exact-out actions from the output end. The input and Amount come from
the first action in that order, the output and Min. received from the
last. SETTLE_ALL and TAKE_ALL were not read before, and the first swap
action set both sides, so a chain showed the first one's output.

Model: opus-5-5
2026-10-18 19:35:29 +00:00
3 changed files with 195 additions and 26 deletions
+26 -13
View File
@@ -1164,8 +1164,9 @@ read:
amount of one step that names an input token or amount: the last
`PERMIT2_PERMIT` step if there is one, otherwise the first V2 or V3 exact-in,
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
`amountInMax`, and a V4 swap step the `amountIn` of its first readable
exact-in action.
`amountInMax`, and a V4 swap step the `amountIn` of the swap action that
spends what the user pays (see below), or none when that action is exact-out
or could not be read.
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
The router reads that zero as "the pair already holds the input tokens": the
@@ -1186,17 +1187,29 @@ read:
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for
that side, or `Unknown (could not be decoded)` instead when a V4 swap action in
the transaction could not be read (see Transaction Decoding below). A V4 step
whose swap action could not be read still sets each side as a swap step does,
and a side its `SETTLE` or `TAKE` did not name is then unknown: no other step's
token or figure is shown for it. An `UNWRAP_WETH` step makes `Token Out` ETH
only when the output side is WETH, on mainnet or Sepolia, or when no step set
the output side; a WETH `Min. received` figure then reads in ETH. Otherwise
`Token Out` and `Min. received` keep the output side's own token and figure,
whether the swap was paid in ETH or in a token: a V2 exact-out swap that buys
USDC and then unwraps the WETH it did not spend shows USDC. The token permission
warning on the signature screen has its own amount wording, including `Unknown`;
the SignApproval section below describes it.
the transaction could not be read (see Transaction Decoding below). A V4 step's
input side is the currency its `SETTLE` or `SETTLE_ALL` names, and its output
side the currency its `TAKE` or `TAKE_ALL` names. A side none of them names
comes from the step's swap actions, put in the order the currencies pass through
them, from what the user pays to what the user receives. The router runs
exact-in actions from the input end, so each one goes after the actions before
it, whose output it spends. It runs exact-out actions from the output end, so
the first one buys what the user receives and each later one goes in front of
the actions before it, buying what they spend. The input side and `Amount` come
from the first action in that order, and the output side and `Min. received`
from the last; the decoder reads no amount from an exact-out action. A swap
action that could not be read keeps its place in that order and states nothing,
so a side or figure that would come from it is unknown, while one from a
readable action is shown. Such a step still sets each side as a swap step does,
and no other step's token or figure is shown for an unknown one. An
`UNWRAP_WETH` step makes `Token Out` ETH only when the output side is WETH, on
mainnet or Sepolia, or when no step set the output side; a WETH `Min. received`
figure then reads in ETH. Otherwise `Token Out` and `Min. received` keep the
output side's own token and figure, whether the swap was paid in ETH or in a
token: a V2 exact-out swap that buys USDC and then unwraps the WETH it did not
spend shows USDC. The token permission warning on the signature screen has its
own amount wording, including `Unknown`; the SignApproval section below
describes it.
The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g.
`2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
+27 -13
View File
@@ -412,10 +412,15 @@ function decodeV4SwapAction(actionId, param) {
// The input is ABI-encoded as (bytes actions, bytes[] params).
// SETTLE or SETTLE_ALL names the step's input currency, and TAKE or TAKE_ALL
// its output currency, wherever in the step they stand. A side neither one
// names is read from the swap actions. In a chain of swap actions each one
// spends what the one before it bought, so the input is what the first swap
// action spends and the output is what the last one buys. Amount comes from
// the first swap action and Min. received from the last.
// names is read from the swap actions, put in the order the currencies pass
// through them, from what the user pays to what the user receives. The router
// runs exact-in actions from the input end: each one spends what the actions
// before it bought, so it goes after them. It runs exact-out actions from the
// output end: the first one buys what the user receives, and each later one
// buys what the actions before it spend, so it goes in front of them. The
// input and Amount come from the first action in that order, and the output
// and Min. received from the last. A swap action that could not be read keeps
// its place and states nothing.
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
@@ -424,8 +429,8 @@ function decodeV4Swap(input) {
let settleToken = null;
let takeToken = null;
// The step's swap actions in order, as decodeV4SwapAction() reads
// them.
// The step's swap actions, as decodeV4SwapAction() reads them, from
// what the user pays to what the user receives.
const swaps = [];
// Set when a swap action's parameters are not in the deployed shape
// (see decodeExactly()), so nothing was read from that action. SETTLE,
@@ -463,11 +468,19 @@ function decodeV4Swap(input) {
actionId === V4_SWAP_EXACT_OUT_SINGLE ||
actionId === V4_SWAP_EXACT_OUT
) {
let swap = NO_SWAP_READ;
try {
swaps.push(decodeV4SwapAction(actionId, params[i]));
swap = decodeV4SwapAction(actionId, params[i]);
} catch {
swapUndecoded = true;
swaps.push(NO_SWAP_READ);
}
if (
actionId === V4_SWAP_EXACT_OUT_SINGLE ||
actionId === V4_SWAP_EXACT_OUT
) {
swaps.unshift(swap);
} else {
swaps.push(swap);
}
}
} catch {
@@ -612,11 +625,12 @@ function decode(data, toAddress, sources) {
if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]);
if (v4 && v4.swapUndecoded) {
// The unread swap action named this step's currencies
// and amounts, so the step is marked and still sets
// both sides as a swap step does. A side its SETTLE or
// TAKE did not name is set undetermined, not left to
// an earlier or later step's token and figure.
// A swap action of this step went unread, so the step
// is marked and still sets both sides as a swap step
// does. A currency or figure that neither SETTLE,
// SETTLE_ALL, TAKE, TAKE_ALL nor a readable swap
// action gave decodeV4Swap() is set undetermined, not
// left to an earlier or later step's token and figure.
swapUndecoded = true;
commandNames[i] += " (could not be decoded)";
if (!inputEstablished) {
+142
View File
@@ -107,6 +107,7 @@ function encodeV4Swap(actions, params) {
// V4 inner action IDs, as src/shared/uniswap.js names them.
const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_IN_SINGLE_ID = 0x06;
const V4_SWAP_EXACT_OUT_SINGLE_ID = 0x08;
const V4_SETTLE_ID = 0x0b;
const V4_TAKE_ID = 0x0e;
const ZERO_ADDR = "0x0000000000000000000000000000000000000000";
@@ -163,6 +164,31 @@ function encodeV4ExactInSingle(currency0, currency1, amountIn, amountOutMin) {
);
}
// Helper: V4 ExactOutputSingleParams —
// (tuple(address,address,uint24,int24,address) poolKey, bool zeroForOne,
// uint128 amountOut, uint128 amountInMaximum, bytes hookData)
function encodeV4ExactOutSingle(
currency0,
currency1,
amountOut,
amountInMaximum,
) {
return coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[
[
[currency0, currency1, 100, 1, ZERO_ADDR],
true, // zeroForOne: in = currency0, out = currency1
amountOut,
amountInMaximum,
"0x",
],
],
);
}
// Each V4 swap action's parameters in two shapes: the struct V4Router has
// deployed on mainnet and Sepolia, and the one v4-periphery `main` has since
// moved to, which adds a `minHopPriceX36` field. Each swaps WETH for USDC.
@@ -1201,6 +1227,122 @@ describe("uniswap decoder", () => {
},
);
// The router runs a chain of exact-out actions from the output end: the
// first buys the USDC the user receives, owing WETH, and the second buys
// that WETH (the open delta) with the USDT the user pays.
test("two chained V4 exact-out actions show the last one's input and the first one's output", () => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SWAP_EXACT_OUT_SINGLE_ID,
]),
[
// exactly 990 USDC for at most 0.5 WETH
encodeV4ExactOutSingle(
WETH_ADDR,
USDC_ADDR,
990000000n,
500000000000000000n,
),
// exactly that WETH (the open delta) for at most
// 1,000 USDT
encodeV4ExactOutSingle(
USDT_ADDR,
WETH_ADDR,
0n,
1000000000n,
),
],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
});
// A swap action that could not be read still stands first or last in its
// step, so the side and figure it would have given are unknown, while
// those from the readable action are shown. The unread action is
// V4_SWAP_ACTION_SHAPES[0].newer, a SWAP_EXACT_IN_SINGLE of WETH to USDC.
test("a readable V4 swap action, then an unread one, shows the first one's input and no output", () => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
WETH_ADDR,
1000000000n,
400000000000000000n,
),
V4_SWAP_ACTION_SHAPES[0].newer,
],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Token Out").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Min. received")).toBeUndefined();
});
test("an unread V4 swap action, then a readable one, shows the last one's output and no input", () => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
]),
[
V4_SWAP_ACTION_SHAPES[0].newer,
encodeV4ExactInSingle(
USDC_ADDR,
DAI_ADDR,
0n,
990000000000000000000n,
),
],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(detail(result, "Token In").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe(DAI_ADDR);
expect(detail(result, "Min. received").value).toBe("990.0000 DAI");
});
// https://git.eeqj.de/sneak/AutistMask/issues/415 — the router's V2
// exact-in reads an amountIn of zero as universal-router
// Constants.ALREADY_PAID: an earlier step sent the tokens to the pair, and