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 what SETTLE or SETTLE_ALL names, else the one
currency its swap actions spend and never buy; its output side is what
TAKE or TAKE_ALL names, else the one they buy and never spend. Amount sums
the amountIn of the actions spending the input side, shown only when each
states one; Min. received likewise. An unread swap action leaves the sides
to SETTLE, SETTLE_ALL, TAKE and TAKE_ALL, with no figure. A step with a
swap action always sets the output side, sets the input side only if no
earlier step did, and leaves a side its rules cannot name unknown, with no
figure. SWAP_EXACT_OUT spends its first path entry.

Model: opus-5-5
This commit was merged in pull request #566.
This commit is contained in:
2026-10-19 02:50:16 +02:00
parent e2e1c5d40e
commit b923b2c19c
3 changed files with 735 additions and 166 deletions
+39 -15
View File
@@ -1168,11 +1168,13 @@ read:
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. The line shows the
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.
amount of the step that sets the input side: 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 that names an input token or amount. A V4 step with a swap
action sets the input side when no earlier step set it, even when it names
neither, as described below. A V2 exact-out step gives its `amountInMax`, and
a V4 swap step the `amountIn` of the swap action that spends its input
currency.
- `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
@@ -1194,16 +1196,38 @@ 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.
with a swap action always sets the output side, and sets the input side only
when no earlier step set it. It finds each side by three rules:
1. Its input side is the currency its `SETTLE` or `SETTLE_ALL` names, or else
the one currency that some swap action in the step spends and none buys. Its
output side is the currency its `TAKE` or `TAKE_ALL` names, or else the one
currency that some swap action buys and none spends. If there is not exactly
one such currency, that side is unknown, and the step sets it as unknown with
no figure. A step that buys WETH with USDT and USDC with that WETH, with
nothing naming its sides, reads `Swap USDT → USDC`.
2. Its `Amount` is the `amountIn` of the swap action that spends the input
currency. If several exact-in actions spend it, each with a stated amount, it
is their sum; in any other case the step gives no `Amount`. The decoder reads
no amount from an exact-out action, and an open delta is not a stated amount.
Its `Min. received` is found the same way, from the `amountOutMin` of the
actions that buy the output currency.
3. If any swap action in the step could not be read, only `SETTLE`,
`SETTLE_ALL`, `TAKE` and `TAKE_ALL` name its sides, and the step gives no
`Amount` or `Min. received`. A side they did not name is unknown, and the
step sets it as unknown with no figure.
A V4 step with no swap action sets only the sides its `SETTLE`, `SETTLE_ALL`,
`TAKE` or `TAKE_ALL` names.
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
+222 -131
View File
@@ -290,7 +290,9 @@ const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_OUT_SINGLE = 0x08;
const V4_SWAP_EXACT_OUT = 0x09;
const V4_SETTLE = 0x0b;
const V4_SETTLE_ALL = 0x0c;
const V4_TAKE = 0x0e;
const V4_TAKE_ALL = 0x0f;
// 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
@@ -318,10 +320,133 @@ function decodeExactly(types, data) {
return decoded;
}
// 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,
};
}
// The currencies in `list` that `other` does not hold, each listed once.
function currenciesNotIn(list, other) {
return [...new Set(list.filter((c) => !other.includes(c)))];
}
// The amount that the swap actions on one side of a V4 step state together:
// the one action's own amount, or the sum when there are several and each
// states a quantity. Exact-out actions state none here, and the open delta is
// not a quantity. Otherwise null.
function totalAmount(amounts) {
if (amounts.length === 1) return amounts[0];
if (amounts.length === 0) return null;
if (!amounts.every((a) => typeof a === "bigint")) return null;
return amounts.reduce((sum, a) => sum + a, 0n);
}
// Decode V4_SWAP (command 0x10) input bytes.
// The input is ABI-encoded as (bytes actions, bytes[] params).
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
// and swap amounts from the swap sub-actions.
// The input is ABI-encoded as (bytes actions, bytes[] params). Each swap
// action gives the currency it spends and the currency it buys (see
// decodeV4SwapAction()). The step's sides then follow three rules:
//
// 1. Token In is the currency SETTLE or SETTLE_ALL names; otherwise the one
// currency that some swap action spends and none buys. Token Out is the
// currency TAKE or TAKE_ALL names; otherwise the one currency that some
// swap action buys and none spends. If there is not exactly one such
// currency, that side is unknown.
// 2. Amount is the amountIn of the swap action that spends Token In. If
// several exact-in actions spend it, each with a stated amount, it is
// their sum; in any other case there is none. Min. received is the same
// for amountOutMin and the actions that buy Token Out.
// 3. If any swap action in the step cannot be read, only SETTLE, SETTLE_ALL,
// TAKE and TAKE_ALL name the sides, and there is no Amount or Min.
// received.
//
// A currency a swap action does not name, such as what an exact-in action
// with an empty path buys, is null and counts in rules 1 and 2 as a currency
// like any other: a step of only that action has an unknown Token Out and
// the action's amountOutMin as Min. received.
//
// `hasSwapAction` is true when the step has a swap action, read or not.
// decode() then always sets the output side from this step, and sets the
// input side from it only when no earlier step set it. A side it sets that
// these rules leave unknown is set unknown, with no figure. A step with no
// swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL
// name.
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
@@ -330,11 +455,12 @@ function decodeV4Swap(input) {
let settleToken = null;
let takeToken = null;
let amountIn = null;
let amountOutMin = null;
// The step's swap actions that could be read, as decodeV4SwapAction()
// reads them.
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
// and TAKE may still name both currencies.
// (see decodeExactly()), so nothing was read from that action. SETTLE,
// SETTLE_ALL, TAKE and TAKE_ALL may still name both currencies.
let swapUndecoded = false;
for (let i = 0; i < actions.length; i++) {
@@ -347,6 +473,10 @@ function decodeV4Swap(input) {
params[i],
);
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) {
// TAKE: (address currency, address recipient, uint256 amount)
const t = coder.decode(
@@ -354,107 +484,20 @@ function decodeV4Swap(input) {
params[i],
);
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 (
actionId === V4_SWAP_EXACT_IN_SINGLE ||
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
if (actionId === V4_SWAP_EXACT_IN) {
// ExactInputParams: (address currencyIn,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountIn, uint128 amountOutMin)
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 (
actionId === V4_SWAP_EXACT_OUT ||
actionId === V4_SWAP_EXACT_OUT_SINGLE
) {
if (actionId === V4_SWAP_EXACT_OUT) {
try {
const s = decodeExactly(
[
"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 {
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];
} catch {
swapUndecoded = true;
}
try {
swaps.push(decodeV4SwapAction(actionId, params[i]));
} catch {
swapUndecoded = true;
}
}
} catch {
@@ -462,12 +505,54 @@ function decodeV4Swap(input) {
}
}
// Rule 3.
if (swapUndecoded) {
return {
tokenIn: settleToken,
tokenOut: takeToken,
amountIn: null,
amountOutMin: null,
swapUndecoded,
hasSwapAction: true,
};
}
const spent = swaps.map((s) => s.tokenIn);
const bought = swaps.map((s) => s.tokenOut);
// Rules 1 and 2 for Token In.
let tokenIn = null;
let amountIn = null;
const spentOnly = currenciesNotIn(spent, bought);
if (present(settleToken) || spentOnly.length === 1) {
tokenIn = present(settleToken) ? settleToken : spentOnly[0];
amountIn = totalAmount(
swaps
.filter((s) => s.tokenIn === tokenIn)
.map((s) => s.amountIn),
);
}
// Rules 1 and 2 for Token Out.
let tokenOut = null;
let amountOutMin = null;
const boughtOnly = currenciesNotIn(bought, spent);
if (present(takeToken) || boughtOnly.length === 1) {
tokenOut = present(takeToken) ? takeToken : boughtOnly[0];
amountOutMin = totalAmount(
swaps
.filter((s) => s.tokenOut === tokenOut)
.map((s) => s.amountOutMin),
);
}
return {
tokenIn: settleToken,
tokenOut: takeToken,
tokenIn,
tokenOut,
amountIn,
amountOutMin,
swapUndecoded,
hasSwapAction: swaps.length > 0,
};
} catch {
return null;
@@ -491,6 +576,7 @@ function decode(data, toAddress, sources) {
let inputEstablished = false;
let outputToken = null;
let minOutput = null;
let outputEstablished = false;
let hasUnwrapWeth = false;
let hasV2ExactOut = false;
let swapUndecoded = false;
@@ -510,8 +596,11 @@ function decode(data, toAddress, sources) {
//
// The input side is fixed by the first hop that states either half, the
// output side by the last, because the final leg is what the user
// receives. A half the establishing hop did not state stays null and
// the line says so, rather than being filled in from a different hop.
// receives. A V4 step with a swap action sets a side even when it
// states neither half: the output side always, and the input side
// when no earlier hop set it. A half the establishing hop did not
// state stays null and the line says so, rather than being filled in
// from a different hop.
const setInput = (token, amount) => {
inputToken = present(token) ? token : null;
inputAmount = present(amount) ? amount : null;
@@ -526,6 +615,7 @@ function decode(data, toAddress, sources) {
if (!present(token) && !present(amount)) return;
outputToken = present(token) ? token : null;
minOutput = present(amount) ? amount : null;
outputEstablished = true;
};
for (let i = 0; i < commandsBytes.length; i++) {
@@ -597,30 +687,33 @@ 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.
swapUndecoded = true;
commandNames[i] += " (could not be decoded)";
}
if (v4 && v4.hasSwapAction) {
// A step with a swap action always sets the output
// side, as the last swap step wins, and sets the
// input side only when no earlier step set it. Each
// side it sets is set as a pair, even when the step
// states neither half, so a side decodeV4Swap() left
// unknown is set unknown with no figure. A token
// stated without a figure, or a figure without a
// token, likewise never sits beside an earlier step's
// other half.
if (!inputEstablished) {
setInput(v4.tokenIn, v4.amountIn);
}
outputToken = v4.tokenOut;
minOutput = v4.amountOutMin;
outputEstablished = true;
} else if (v4) {
// A step with no swap action sets only the sides its
// SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names:
// setInputOnce() and setOutput() leave alone a side
// whose two halves are both unstated.
setInputOnce(v4.tokenIn, v4.amountIn);
// Always update output: last swap step wins. A step
// that carries the Min. received figure but decoded no
// output currency makes the output *undetermined* — it
// is neither ETH nor whatever an earlier step named,
// and that figure is no longer counted in that token.
// Equally, a step that names an output currency but no
// minimum leaves Min. received unstated rather than
// keeping an earlier step's figure beside the new
// token. setOutput() is both rules; a step that states
// neither half leaves the output alone.
setOutput(v4.tokenOut, v4.amountOutMin);
}
}
@@ -634,21 +727,19 @@ function decode(data, toAddress, sources) {
}
// Resolve token info. A null token on either side means no step named
// a currency for it, or a V4 step whose swap action went unread left
// it undetermined; tokenInfo() refuses rather than calling it ETH.
// a currency for it, or a V4 step with a swap action left it
// unknown; tokenInfo() refuses rather than calling it ETH.
// UNWRAP_WETH turns WETH into ETH, so it makes the output ETH when the
// output side is WETH, or when no step set the output side. A step
// whose swap action went unread did set it, to undetermined. Any other
// output side is WETH, or when no step set the output side. A V4 step
// with a swap action did set it, even when it set it unknown. Any other
// output keeps its own token and figure: a swap that buys USDC and
// then unwraps the WETH it did not spend receives USDC.
const outputIsWeth =
present(outputToken) &&
WETH_ADDRESSES.includes(outputToken.toLowerCase());
const outputUnset =
!present(outputToken) && !present(minOutput) && !swapUndecoded;
const inInfo = tokenInfo(inputToken, sources);
const outInfo =
hasUnwrapWeth && (outputIsWeth || outputUnset)
hasUnwrapWeth && (outputIsWeth || !outputEstablished)
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources);
+474 -20
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.
@@ -267,9 +293,7 @@ function v4SwapCalldata(actionId, swapParams) {
);
}
// V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount). The
// decoder reads neither, so in a V4_SWAP of a swap action and these, only the
// swap action names the currencies.
// V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount).
const V4_SETTLE_ALL_ID = 0x0c;
const V4_TAKE_ALL_ID = 0x0f;
function encodeV4SettleAll(currency, maxAmount) {
@@ -279,6 +303,12 @@ function encodeV4TakeAll(currency, minAmount) {
return coder.encode(["address", "uint256"], [currency, minAmount]);
}
// V4 CLOSE_CURRENCY (currency), which the decoder does not read.
const V4_CLOSE_CURRENCY_ID = 0x12;
function encodeV4CloseCurrency(currency) {
return coder.encode(["address"], [currency]);
}
function detail(result, label) {
return result.details.find((d) => d.label === label);
}
@@ -358,10 +388,9 @@ describe("uniswap decoder", () => {
},
);
// Only the unread swap action names the currencies here. The token lines
// must not say the calldata did not name them.
// SETTLE_ALL and TAKE_ALL name both sides as SETTLE and TAKE do.
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 }) => {
const data = buildExecute(
"0x10",
@@ -381,6 +410,27 @@ describe("uniswap decoder", () => {
expect(detail(result, "Steps").value).toBe(
"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(
"Unknown (could not be decoded)",
);
@@ -394,7 +444,7 @@ describe("uniswap decoder", () => {
// action names. The V3 step's WETH and its Min. received figure were
// shown instead.
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 }) => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
@@ -403,10 +453,7 @@ describe("uniswap decoder", () => {
USDT_ADDR,
WETH_ADDR,
]),
encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [
newer,
encodeV4TakeAll(USDC_ADDR, 990000n),
]),
encodeV4Swap(new Uint8Array([id]), [newer]),
],
9999999999n,
);
@@ -423,15 +470,12 @@ describe("uniswap decoder", () => {
// 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.
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 }) => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x00]),
[
encodeV4Swap(new Uint8Array([id, V4_SETTLE_ALL_ID]), [
newer,
encodeV4SettleAll(WETH_ADDR, ONE_WETH),
]),
encodeV4Swap(new Uint8Array([id]), [newer]),
encodeV3SwapExactIn(
USER_ADDR,
990000n,
@@ -458,10 +502,7 @@ describe("uniswap decoder", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
[
encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [
newer,
encodeV4TakeAll(USDC_ADDR, 990000n),
]),
encodeV4Swap(new Uint8Array([id]), [newer]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding
],
9999999999n,
@@ -1124,6 +1165,419 @@ describe("uniswap decoder", () => {
expect(detail(result, "Min. received").value).toBe("0.9900 USDC");
});
// https://git.eeqj.de/sneak/AutistMask/issues/563 — a V4 step of several
// swap actions. The screen showed the first action's output as Token Out.
// Every step below pays USDT and receives USDC, so it reads Swap USDT →
// USDC whichever kind of swap actions it holds and in whatever order they
// are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL,
// SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the
// sides to the swap actions alone.
const V4_STEP_ENDINGS = [
{
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)],
},
{
ending: "CLOSE_CURRENCY",
ids: [
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
],
params: [
encodeV4CloseCurrency(USDT_ADDR),
encodeV4CloseCurrency(WETH_ADDR),
encodeV4CloseCurrency(USDC_ADDR),
],
},
];
// 1,000 USDT for at least 0.5 WETH
const USDT_FOR_WETH_IN = {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
USDT_ADDR,
WETH_ADDR,
1000000000n,
500000000000000000n,
),
};
// 0.5 WETH for at least 990 USDC
const WETH_FOR_USDC_IN = {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
500000000000000000n,
990000000n,
),
};
// exactly 0.5 WETH for at most 1,000 USDT
const USDT_FOR_WETH_OUT = {
id: V4_SWAP_EXACT_OUT_SINGLE_ID,
param: encodeV4ExactOutSingle(
USDT_ADDR,
WETH_ADDR,
500000000000000000n,
1000000000n,
),
};
// exactly 990 USDC for at most 0.5 WETH
const WETH_FOR_USDC_OUT = {
id: V4_SWAP_EXACT_OUT_SINGLE_ID,
param: encodeV4ExactOutSingle(
WETH_ADDR,
USDC_ADDR,
990000000n,
500000000000000000n,
),
};
// 400 USDT for at least 396 USDC
const USDT_FOR_USDC_IN = {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
400000000n,
396000000n,
),
};
// 600 USDT through WETH for at least 594 USDC
const USDT_FOR_USDC_THROUGH_WETH_IN = {
id: V4_SWAP_EXACT_IN,
param: encodeV4ExactIn(
USDT_ADDR,
[WETH_ADDR, USDC_ADDR],
600000000n,
594000000n,
),
};
// the open delta of USDT through WETH for at least 594 USDC
const USDT_OPEN_DELTA_FOR_USDC_THROUGH_WETH_IN = {
id: V4_SWAP_EXACT_IN,
param: encodeV4ExactIn(
USDT_ADDR,
[WETH_ADDR, USDC_ADDR],
0n,
594000000n,
),
};
// Amount is the amountIn of the action that spends USDT, or the sum when
// several exact-in actions each state one. Min. received is the same for
// the actions that buy USDC. The decoder reads no amount from an exact-out
// action, and an open delta is not a stated amount.
const V4_STEPS = [
{
step: "USDT for WETH exact-in, then WETH for USDC exact-in",
swaps: [USDT_FOR_WETH_IN, WETH_FOR_USDC_IN],
amount: "1000.0000 USDT",
min: "990.0000 USDC",
},
{
step: "WETH for USDC exact-in, then USDT for WETH exact-in",
swaps: [WETH_FOR_USDC_IN, USDT_FOR_WETH_IN],
amount: "1000.0000 USDT",
min: "990.0000 USDC",
},
{
step: "WETH for USDC exact-out, then USDT for WETH exact-out",
swaps: [WETH_FOR_USDC_OUT, USDT_FOR_WETH_OUT],
amount: undefined,
min: undefined,
},
{
step: "USDT for WETH exact-out, then WETH for USDC exact-out",
swaps: [USDT_FOR_WETH_OUT, WETH_FOR_USDC_OUT],
amount: undefined,
min: undefined,
},
{
step: "USDT for WETH exact-in, then WETH for USDC exact-out",
swaps: [USDT_FOR_WETH_IN, WETH_FOR_USDC_OUT],
amount: "1000.0000 USDT",
min: undefined,
},
{
step: "WETH for USDC exact-out, then USDT for WETH exact-in",
swaps: [WETH_FOR_USDC_OUT, USDT_FOR_WETH_IN],
amount: "1000.0000 USDT",
min: undefined,
},
{
step: "USDT for WETH exact-out, then WETH for USDC exact-in",
swaps: [USDT_FOR_WETH_OUT, WETH_FOR_USDC_IN],
amount: undefined,
min: "990.0000 USDC",
},
{
step: "USDT for USDC, then USDT for USDC through WETH, both exact-in",
swaps: [USDT_FOR_USDC_IN, USDT_FOR_USDC_THROUGH_WETH_IN],
amount: "1000.0000 USDT",
min: "990.0000 USDC",
},
{
step: "USDT for USDC, then the open delta of USDT for USDC through WETH, both exact-in",
swaps: [USDT_FOR_USDC_IN, USDT_OPEN_DELTA_FOR_USDC_THROUGH_WETH_IN],
amount: undefined,
min: "990.0000 USDC",
},
];
test.each(
V4_STEPS.flatMap((step) =>
V4_STEP_ENDINGS.map((ending) => ({ ...step, ...ending })),
),
)(
"a V4 step of $step, then $ending, reads Swap USDT → USDC",
({ swaps, ids, params, amount, min }) => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([...swaps.map((s) => s.id), ...ids]),
[...swaps.map((s) => s.param), ...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, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Amount")?.value).toBe(amount);
expect(detail(result, "Min. received")?.value).toBe(min);
},
);
// A step with a swap action that could not be read is named only by its
// SETTLE_ALL, TAKE_ALL, SETTLE and TAKE, and states no figure: its readable
// USDT for WETH action names neither side. The unread action is
// V4_SWAP_ACTION_SHAPES[0].newer, WETH for USDC exact-in in the newer
// shape.
test.each([
{
...V4_STEP_ENDINGS[0],
tokenIn: `USDT (${USDT_ADDR})`,
tokenOut: `USDC (${USDC_ADDR})`,
},
{
...V4_STEP_ENDINGS[1],
tokenIn: `USDT (${USDT_ADDR})`,
tokenOut: `USDC (${USDC_ADDR})`,
},
{
...V4_STEP_ENDINGS[2],
tokenIn: "Unknown (could not be decoded)",
tokenOut: "Unknown (could not be decoded)",
},
])(
"a V4 step of a readable swap action and an unread one, then $ending, shows no figure",
({ ids, params, tokenIn, tokenOut }) => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([
USDT_FOR_WETH_IN.id,
V4_SWAP_ACTION_SHAPES[0].id,
...ids,
]),
[
USDT_FOR_WETH_IN.param,
V4_SWAP_ACTION_SHAPES[0].newer,
...params,
],
),
],
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(tokenIn);
expect(detail(result, "Token Out").value).toBe(tokenOut);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
},
);
// A V4 step that spends WETH on USDC and on DAI. Its swap actions buy two
// currencies that none of them spends, and CLOSE_CURRENCY, which the
// decoder does not read, names no side, so its output side is unknown.
const WETH_FOR_USDC_AND_DAI = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
]),
[
// 0.25 WETH for at least 495 USDC
encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
250000000000000000n,
495000000n,
),
// 0.25 WETH for at least 495 DAI
encodeV4ExactInSingle(
WETH_ADDR,
DAI_ADDR,
250000000000000000n,
495000000000000000000n,
),
encodeV4CloseCurrency(WETH_ADDR),
encodeV4CloseCurrency(USDC_ADDR),
encodeV4CloseCurrency(DAI_ADDR),
],
);
// The user ends with USDC and DAI, not the V3 step's WETH. That WETH and
// the V3 step's Min. received figure were shown instead.
test("a V3 step, then a V4 step whose swap actions buy two currencies, does not show the V3 step's output", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(
USER_ADDR,
1000000000n,
500000000000000000n,
[USDT_ADDR, WETH_ADDR],
),
WETH_FOR_USDC_AND_DAI,
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Min. received")).toBeUndefined();
});
// The input side of the same gap: the V4 step's swap actions spend USDC
// and DAI, two currencies that none of them buys, and nothing names its
// input side. The V3 step's WETH and its amount were shown instead.
test("a V4 step whose swap actions spend two currencies, then a V3 step, does not show the V3 step's input", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x00]),
[
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID,
]),
[
// 500 USDC for at least 0.25 WETH
encodeV4ExactInSingle(
USDC_ADDR,
WETH_ADDR,
500000000n,
250000000000000000n,
),
// 500 DAI for at least 0.25 WETH
encodeV4ExactInSingle(
DAI_ADDR,
WETH_ADDR,
500000000000000000000n,
250000000000000000n,
),
encodeV4CloseCurrency(USDC_ADDR),
encodeV4CloseCurrency(DAI_ADDR),
encodeV4CloseCurrency(WETH_ADDR),
],
),
encodeV3SwapExactIn(
USER_ADDR,
500000000000000000n,
990000000n,
[WETH_ADDR, USDT_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Min. received").value).toBe("990.0000 USDT");
});
// UNWRAP_WETH makes Token Out ETH when no step set the output side. A V4
// step with a swap action did set it, to unknown here.
test("an UNWRAP_WETH after a V4 step whose swap actions buy two currencies does not make Token Out ETH", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
[
WETH_FOR_USDC_AND_DAI,
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
});
// 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);
});
// 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