fix: a V4 step of chained swap actions shows what the user receives (closes #563)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

A V4 step's input side is now the currency its SETTLE or SETTLE_ALL
names, and its output side the one its TAKE or TAKE_ALL names. A side
none of them names comes from the swap actions, lined up as the
exact-out ones in reverse order and then the exact-in ones in order,
since the router runs exact-out actions from the output end: the input
from the first, the output from the last. Amount is the first action's
amountIn and Min. received the last one's minimum, each shown only when
that action spends the input currency or buys the output currency.
SETTLE_ALL and TAKE_ALL were not read before, the first swap action set
both sides, and a SWAP_EXACT_OUT was read as spending its path's last
currency instead of its first.

Model: opus-5-5
This commit is contained in:
2026-10-18 21:02:23 +00:00
parent 1f5ec1c8de
commit 6fc42063d2
3 changed files with 512 additions and 144 deletions
+33 -13
View File
@@ -1164,8 +1164,9 @@ read:
amount of one step that names an input token or amount: the last 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, `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 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 `amountInMax`, and a V4 swap step the `amountIn` of the first of its swap
exact-in action. actions as they are lined up below, or none when that action is exact-out,
could not be read or does not spend the step's input currency.
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's - `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. `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 The router reads that zero as "the pair already holds the input tokens": the
@@ -1186,17 +1187,36 @@ read:
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each 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 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 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 the transaction could not be read (see Transaction Decoding below). A V4 step's
whose swap action could not be read still sets each side as a swap step does, input side is the currency its `SETTLE` or `SETTLE_ALL` names, and its output
and a side its `SETTLE` or `TAKE` did not name is then unknown: no other step's side the currency its `TAKE` or `TAKE_ALL` names. A side none of them names
token or figure is shown for it. An `UNWRAP_WETH` step makes `Token Out` ETH comes from the step's swap actions, lined up as the exact-out actions in reverse
only when the output side is WETH, on mainnet or Sepolia, or when no step set order, then the exact-in actions in the order they appear: the input side from
the output side; a WETH `Min. received` figure then reads in ETH. Otherwise the first action in that line, the output side from the last. The router runs
`Token Out` and `Min. received` keep the output side's own token and figure, exact-in actions from the input end and a chain of exact-out actions from the
whether the swap was paid in ETH or in a token: a V2 exact-out swap that buys output end, so a step whose swap actions are all of one kind is lined up from
USDC and then unwraps the WETH it did not spend shows USDC. The token permission what the user pays to what the user receives. A step that mixes the two kinds
warning on the signature screen has its own amount wording, including `Unknown`; need not be: one that buys WETH with USDT exact-in and then USDC with that WETH
the SignApproval section below describes it. exact-out is lined up with the USDC purchase first, so with no `SETTLE`,
`SETTLE_ALL`, `TAKE` or `TAKE_ALL` it reads `Swap WETH → WETH`. `Amount` is the
first action's `amountIn`, shown only when that action spends the step's input
currency, and `Min. received` the last action's `amountOutMin`, shown only when
that action buys the step's output currency, so no figure is shown against a
currency it is not counted in. The decoder reads no amount from an exact-out
action. That mixed step, ending in `SETTLE_ALL` USDT and `TAKE_ALL` USDC, reads
`Swap USDT → USDC` with neither figure, since its first action in the line is
exact-out and its last buys WETH. A swap action that could not be read keeps its
place in the line 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. 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 `2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
+153 -111
View File
@@ -290,7 +290,9 @@ const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_OUT_SINGLE = 0x08; const V4_SWAP_EXACT_OUT_SINGLE = 0x08;
const V4_SWAP_EXACT_OUT = 0x09; const V4_SWAP_EXACT_OUT = 0x09;
const V4_SETTLE = 0x0b; const V4_SETTLE = 0x0b;
const V4_SETTLE_ALL = 0x0c;
const V4_TAKE = 0x0e; const V4_TAKE = 0x0e;
const V4_TAKE_ALL = 0x0f;
// A V4 exact-in `amountIn`, read the way V4Router reads it: zero is // A V4 exact-in `amountIn`, read the way V4Router reads it: zero is
// ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA // ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA
@@ -318,10 +320,116 @@ function decodeExactly(types, data) {
return decoded; return decoded;
} }
// What decodeV4Swap() knows of a swap action it could not read, and of the
// first and last swap action of a step that has none.
const NO_SWAP_READ = {
tokenIn: null,
tokenOut: null,
amountIn: null,
amountOutMin: null,
};
// Decode one V4 swap action's parameters into the currency it spends
// (`tokenIn`), the currency it buys (`tokenOut`) and, for an exact-in action,
// its `amountIn` and `amountOutMin`. The exact-out actions decode no amounts.
// A field the action does not state is null. Throws when the parameters are
// not in the deployed shape (see decodeExactly()).
function decodeV4SwapAction(actionId, param) {
if (actionId === V4_SWAP_EXACT_IN) {
// ExactInputParams: (address currencyIn,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountIn, uint128 amountOutMin)
const s = decodeExactly(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
param,
);
const path = s[0][1];
return {
tokenIn: s[0][0],
tokenOut: path.length > 0 ? path[path.length - 1][0] : null,
amountIn: v4ExactInAmount(s[0][2]),
amountOutMin: s[0][3],
};
}
if (actionId === V4_SWAP_EXACT_IN_SINGLE) {
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
const s = decodeExactly(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
param,
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
return {
tokenIn: zeroForOne ? poolKey[0] : poolKey[1],
tokenOut: zeroForOne ? poolKey[1] : poolKey[0],
amountIn: v4ExactInAmount(s[0][2]),
amountOutMin: s[0][3],
};
}
if (actionId === V4_SWAP_EXACT_OUT) {
// ExactOutputParams: (address currencyOut,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountOut, uint128 amountInMaximum)
const s = decodeExactly(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
param,
);
// The router works through the path from its last entry back to its
// first, starting from currencyOut, so the first entry is what the
// action spends.
const path = s[0][1];
return {
tokenIn: path.length > 0 ? path[0][0] : null,
tokenOut: s[0][0],
amountIn: null,
amountOutMin: null,
};
}
// V4_SWAP_EXACT_OUT_SINGLE. ExactOutputSingleParams: (poolKey as above,
// bool zeroForOne, uint128 amountOut, uint128 amountInMaximum,
// bytes hookData)
const s = decodeExactly(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
param,
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
return {
tokenIn: zeroForOne ? poolKey[0] : poolKey[1],
tokenOut: zeroForOne ? poolKey[1] : poolKey[0],
amountIn: null,
amountOutMin: null,
};
}
// Decode V4_SWAP (command 0x10) input bytes. // Decode V4_SWAP (command 0x10) input bytes.
// The input is ABI-encoded as (bytes actions, bytes[] params). // The input is ABI-encoded as (bytes actions, bytes[] params).
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions, // SETTLE or SETTLE_ALL names the step's input currency, and TAKE or TAKE_ALL
// and swap amounts from the swap sub-actions. // its output currency, wherever in the step they stand. A side neither one
// names is read from the swap actions, lined up as the exact-out actions in
// reverse order, then the exact-in actions in the order they appear. The
// input comes from the first action in that line and the output from the
// last. The router runs exact-in actions from the input end and a chain of
// exact-out actions from the output end, so a step whose swap actions are all
// of one kind is lined up from what the user pays to what the user receives.
// A step that mixes the two kinds need not be: one that buys WETH with USDT
// exact-in and then USDC with that WETH exact-out is lined up with the USDC
// purchase first, so with no SETTLE, SETTLE_ALL, TAKE or TAKE_ALL both its
// sides are WETH. Amount is the first action's amountIn, kept only when that
// action spends the step's input currency, and Min. received the last
// action's amountOutMin, kept only when that action buys the step's output
// currency, so a figure is never shown against a currency other than the one
// it is counted in. A swap action that could not be read keeps its place in
// the line and states nothing.
function decodeV4Swap(input) { function decodeV4Swap(input) {
try { try {
const d = coder.decode(["bytes", "bytes[]"], input); const d = coder.decode(["bytes", "bytes[]"], input);
@@ -330,11 +438,12 @@ function decodeV4Swap(input) {
let settleToken = null; let settleToken = null;
let takeToken = null; let takeToken = null;
let amountIn = null; // The step's swap actions, as decodeV4SwapAction() reads them, lined
let amountOutMin = null; // up as the comment above says.
const swaps = [];
// Set when a swap action's parameters are not in the deployed shape // Set when a swap action's parameters are not in the deployed shape
// (see decodeExactly()), so nothing was read from that action. SETTLE // (see decodeExactly()), so nothing was read from that action. SETTLE,
// and TAKE may still name both currencies. // SETTLE_ALL, TAKE and TAKE_ALL may still name both currencies.
let swapUndecoded = false; let swapUndecoded = false;
for (let i = 0; i < actions.length; i++) { for (let i = 0; i < actions.length; i++) {
@@ -347,6 +456,10 @@ function decodeV4Swap(input) {
params[i], params[i],
); );
settleToken = s[0]; settleToken = s[0];
} else if (actionId === V4_SETTLE_ALL) {
// SETTLE_ALL: (address currency, uint256 maxAmount)
const s = coder.decode(["address", "uint256"], params[i]);
settleToken = s[0];
} else if (actionId === V4_TAKE) { } else if (actionId === V4_TAKE) {
// TAKE: (address currency, address recipient, uint256 amount) // TAKE: (address currency, address recipient, uint256 amount)
const t = coder.decode( const t = coder.decode(
@@ -354,107 +467,31 @@ function decodeV4Swap(input) {
params[i], params[i],
); );
takeToken = t[0]; takeToken = t[0];
} else if (actionId === V4_TAKE_ALL) {
// TAKE_ALL: (address currency, uint256 minAmount)
const t = coder.decode(["address", "uint256"], params[i]);
takeToken = t[0];
} else if ( } else if (
actionId === V4_SWAP_EXACT_IN_SINGLE ||
actionId === V4_SWAP_EXACT_IN || actionId === V4_SWAP_EXACT_IN ||
actionId === V4_SWAP_EXACT_IN_SINGLE actionId === V4_SWAP_EXACT_OUT_SINGLE ||
actionId === V4_SWAP_EXACT_OUT
) { ) {
// Extract amounts from exact-in swap actions let swap = NO_SWAP_READ;
if (actionId === V4_SWAP_EXACT_IN) { try {
// ExactInputParams: (address currencyIn, swap = decodeV4SwapAction(actionId, params[i]);
// tuple(address,uint24,int24,address,bytes)[] path, } catch {
// uint128 amountIn, uint128 amountOutMin) swapUndecoded = true;
try {
const s = decodeExactly(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
params[i],
);
if (!present(settleToken)) settleToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !present(takeToken)) {
takeToken = path[path.length - 1][0];
}
if (!present(amountIn)) {
amountIn = v4ExactInAmount(s[0][2]);
}
if (!present(amountOutMin)) {
amountOutMin = s[0][3];
}
} catch {
swapUndecoded = true;
}
} else {
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
try {
const s = decodeExactly(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
params[i],
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!present(settleToken))
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!present(takeToken))
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
if (!present(amountIn)) {
amountIn = v4ExactInAmount(s[0][2]);
}
if (!present(amountOutMin)) {
amountOutMin = s[0][3];
}
} catch {
swapUndecoded = true;
}
} }
} else if ( // An exact-out action goes in front of the swap actions
actionId === V4_SWAP_EXACT_OUT || // before it, an exact-in one after them.
actionId === V4_SWAP_EXACT_OUT_SINGLE if (
) { actionId === V4_SWAP_EXACT_OUT_SINGLE ||
if (actionId === V4_SWAP_EXACT_OUT) { actionId === V4_SWAP_EXACT_OUT
try { ) {
const s = decodeExactly( swaps.unshift(swap);
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
params[i],
);
if (!present(takeToken)) takeToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !present(settleToken)) {
settleToken = path[path.length - 1][0];
}
} catch {
swapUndecoded = true;
}
} else { } else {
try { swaps.push(swap);
const s = decodeExactly(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
params[i],
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!present(settleToken))
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!present(takeToken))
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
} catch {
swapUndecoded = true;
}
} }
} }
} catch { } catch {
@@ -462,11 +499,15 @@ function decodeV4Swap(input) {
} }
} }
const first = swaps.length > 0 ? swaps[0] : NO_SWAP_READ;
const last = swaps.length > 0 ? swaps[swaps.length - 1] : NO_SWAP_READ;
const tokenIn = present(settleToken) ? settleToken : first.tokenIn;
const tokenOut = present(takeToken) ? takeToken : last.tokenOut;
return { return {
tokenIn: settleToken, tokenIn,
tokenOut: takeToken, tokenOut,
amountIn, amountIn: first.tokenIn === tokenIn ? first.amountIn : null,
amountOutMin, amountOutMin: last.tokenOut === tokenOut ? last.amountOutMin : null,
swapUndecoded, swapUndecoded,
}; };
} catch { } catch {
@@ -597,11 +638,12 @@ function decode(data, toAddress, sources) {
if (cmdId === 0x10) { if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]); const v4 = decodeV4Swap(inputs[i]);
if (v4 && v4.swapUndecoded) { if (v4 && v4.swapUndecoded) {
// The unread swap action named this step's currencies // A swap action of this step went unread, so the step
// and amounts, so the step is marked and still sets // is marked and still sets both sides as a swap step
// both sides as a swap step does. A side its SETTLE or // does. A currency or figure that neither SETTLE,
// TAKE did not name is set undetermined, not left to // SETTLE_ALL, TAKE, TAKE_ALL nor a readable swap
// an earlier or later step's token and figure. // action gave decodeV4Swap() is set undetermined, not
// left to an earlier or later step's token and figure.
swapUndecoded = true; swapUndecoded = true;
commandNames[i] += " (could not be decoded)"; commandNames[i] += " (could not be decoded)";
if (!inputEstablished) { if (!inputEstablished) {
+326 -20
View File
@@ -107,6 +107,7 @@ function encodeV4Swap(actions, params) {
// V4 inner action IDs, as src/shared/uniswap.js names them. // V4 inner action IDs, as src/shared/uniswap.js names them.
const V4_SWAP_EXACT_IN = 0x07; const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_IN_SINGLE_ID = 0x06; const V4_SWAP_EXACT_IN_SINGLE_ID = 0x06;
const V4_SWAP_EXACT_OUT_SINGLE_ID = 0x08;
const V4_SETTLE_ID = 0x0b; const V4_SETTLE_ID = 0x0b;
const V4_TAKE_ID = 0x0e; const V4_TAKE_ID = 0x0e;
const ZERO_ADDR = "0x0000000000000000000000000000000000000000"; 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 // 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 // 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. // moved to, which adds a `minHopPriceX36` field. Each swaps WETH for USDC.
@@ -267,9 +293,7 @@ function v4SwapCalldata(actionId, swapParams) {
); );
} }
// V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount). The // V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount).
// decoder reads neither, so in a V4_SWAP of a swap action and these, only the
// swap action names the currencies.
const V4_SETTLE_ALL_ID = 0x0c; const V4_SETTLE_ALL_ID = 0x0c;
const V4_TAKE_ALL_ID = 0x0f; const V4_TAKE_ALL_ID = 0x0f;
function encodeV4SettleAll(currency, maxAmount) { function encodeV4SettleAll(currency, maxAmount) {
@@ -358,10 +382,9 @@ describe("uniswap decoder", () => {
}, },
); );
// Only the unread swap action names the currencies here. The token lines // SETTLE_ALL and TAKE_ALL name both sides as SETTLE and TAKE do.
// must not say the calldata did not name them.
test.each(V4_SWAP_ACTION_SHAPES)( test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, says both tokens could not be decoded", "a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, takes both tokens from them",
({ id, newer }) => { ({ id, newer }) => {
const data = buildExecute( const data = buildExecute(
"0x10", "0x10",
@@ -381,6 +404,27 @@ describe("uniswap decoder", () => {
expect(detail(result, "Steps").value).toBe( expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)", "V4 Swap (could not be decoded)",
); );
expect(detail(result, "Token In").address).toBe(WETH_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
},
);
// Only the unread swap action names the currencies here. The token lines
// must not say the calldata did not name them.
test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 step of only a $action in the newer shape says both tokens could not be decoded",
({ id, newer }) => {
const data = buildExecute(
"0x10",
[encodeV4Swap(new Uint8Array([id]), [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").value).toBe( expect(detail(result, "Token In").value).toBe(
"Unknown (could not be decoded)", "Unknown (could not be decoded)",
); );
@@ -394,7 +438,7 @@ describe("uniswap decoder", () => {
// action names. The V3 step's WETH and its Min. received figure were // action names. The V3 step's WETH and its Min. received figure were
// shown instead. // shown instead.
test.each(V4_SWAP_ACTION_SHAPES)( test.each(V4_SWAP_ACTION_SHAPES)(
"a V3 step, then a V4 $action in the newer shape and TAKE_ALL, does not show the V3 step's output", "a V3 step, then a V4 step of only a $action in the newer shape, does not show the V3 step's output",
({ id, newer }) => { ({ id, newer }) => {
const data = buildExecute( const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]), solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
@@ -403,10 +447,7 @@ describe("uniswap decoder", () => {
USDT_ADDR, USDT_ADDR,
WETH_ADDR, WETH_ADDR,
]), ]),
encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [ encodeV4Swap(new Uint8Array([id]), [newer]),
newer,
encodeV4TakeAll(USDC_ADDR, 990000n),
]),
], ],
9999999999n, 9999999999n,
); );
@@ -423,15 +464,12 @@ describe("uniswap decoder", () => {
// The user pays the V4 step's WETH, which only its unread swap action // The user pays the V4 step's WETH, which only its unread swap action
// names. The V3 step's USDC and its amount were shown instead. // names. The V3 step's USDC and its amount were shown instead.
test.each(V4_SWAP_ACTION_SHAPES)( test.each(V4_SWAP_ACTION_SHAPES)(
"a V4 $action in the newer shape and SETTLE_ALL, then a V3 step, does not show the V3 step's input", "a V4 step of only a $action in the newer shape, then a V3 step, does not show the V3 step's input",
({ id, newer }) => { ({ id, newer }) => {
const data = buildExecute( const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x00]), solidityPacked(["uint8", "uint8"], [0x10, 0x00]),
[ [
encodeV4Swap(new Uint8Array([id, V4_SETTLE_ALL_ID]), [ encodeV4Swap(new Uint8Array([id]), [newer]),
newer,
encodeV4SettleAll(WETH_ADDR, ONE_WETH),
]),
encodeV3SwapExactIn( encodeV3SwapExactIn(
USER_ADDR, USER_ADDR,
990000n, 990000n,
@@ -458,10 +496,7 @@ describe("uniswap decoder", () => {
const data = buildExecute( const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]), solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
[ [
encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [ encodeV4Swap(new Uint8Array([id]), [newer]),
newer,
encodeV4TakeAll(USDC_ADDR, 990000n),
]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding
], ],
9999999999n, 9999999999n,
@@ -1124,6 +1159,277 @@ describe("uniswap decoder", () => {
expect(detail(result, "Min. received").value).toBe("0.9900 USDC"); expect(detail(result, "Min. received").value).toBe("0.9900 USDC");
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/563 — one V4 step of two
// chained single-pool exact-in swap actions, USDT to WETH and then WETH to
// USDC. The user pays USDT and receives USDC, but the screen showed the
// first action's WETH as Token Out and its minimum as Min. received.
test.each([
{
ending: "SETTLE_ALL and TAKE_ALL",
ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID],
params: [
encodeV4SettleAll(USDT_ADDR, 1000000000n),
encodeV4TakeAll(USDC_ADDR, 990000000n),
],
},
{
ending: "SETTLE and TAKE",
ids: [V4_SETTLE_ID, V4_TAKE_ID],
params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)],
},
{
// Only the swap actions name the currencies.
ending: "nothing",
ids: [],
params: [],
},
])(
"two chained V4 swap actions followed by $ending show the first one's input and the last one's output",
({ ids, params }) => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
...ids,
]),
[
// 1,000 USDT for at least 0.4 WETH
encodeV4ExactInSingle(
USDT_ADDR,
WETH_ADDR,
1000000000n,
400000000000000000n,
),
// all of that WETH (the open delta) for at least
// 990 USDC
encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
0n,
990000000n,
),
...params,
],
),
],
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, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Min. received").value).toBe("990.0000 USDC");
},
);
// The router works through an exact-out path from its last entry back to
// its first: the WETH entry buys the USDC, then the USDT entry buys that
// WETH. The user pays the first entry's USDT, not the last entry's WETH.
test("a two-hop V4 exact-out action shows its first path entry as Token In", () => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(new Uint8Array([0x09]), [
// SWAP_EXACT_OUT: exactly 990 USDC for at most 1,000 USDT
coder.encode(
[`tuple(address,${PATH_KEY}[],uint128,uint128)`],
[
[
USDC_ADDR,
[
[USDT_ADDR, 100, 1, ZERO_ADDR, "0x"],
[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"],
],
990000000n,
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);
});
// 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();
});
// An exact-in action buys WETH with USDT and an exact-out action then buys
// USDC with that WETH. The exact-out action is lined up first, so the last
// action in the line is the exact-in one, and its minimum is counted in
// WETH, not in the USDC the user receives. Shown as Min. received, it would
// read "400000000000.0000 USDC".
test("a V4 exact-in action, then an exact-out one that spends its output, shows no WETH figure as Min. received", () => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
0x13, // CLEAR_OR_TAKE, which the decoder does not read
]),
[
// 1,000 USDT for at least 0.4 WETH
encodeV4ExactInSingle(
USDT_ADDR,
WETH_ADDR,
1000000000n,
400000000000000000n,
),
// exactly 990 USDC for at most 0.5 WETH
encodeV4ExactOutSingle(
WETH_ADDR,
USDC_ADDR,
990000000n,
500000000000000000n,
),
encodeV4SettleAll(USDT_ADDR, 1000000000n),
encodeV4TakeAll(USDC_ADDR, 990000000n),
// the WETH left over: (address currency, uint256 amountMax)
coder.encode(
["address", "uint256"],
[WETH_ADDR, 100000000000000000n],
),
],
),
],
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 // https://git.eeqj.de/sneak/AutistMask/issues/415 — the router's V2
// exact-in reads an amountIn of zero as universal-router // exact-in reads an amountIn of zero as universal-router
// Constants.ALREADY_PAID: an earlier step sent the tokens to the pair, and // Constants.ALREADY_PAID: an earlier step sent the tokens to the pair, and