fix: a V4 step of chained swap actions shows what the user receives (closes #563)
A V4 step's input side is now the currency its SETTLE or SETTLE_ALL names, or else the one currency that some swap action spends and none buys; its output side is the currency TAKE or TAKE_ALL names, or else the one that some swap action buys and none spends. Amount and Min. received come from the actions that spend the input and buy the output, summed when several exact-in actions each state one. A step with an unread swap action is named only by SETTLE, SETTLE_ALL, TAKE and TAKE_ALL, and shows no figure. Before, the first swap action set both sides, SETTLE_ALL and TAKE_ALL were not read, and a SWAP_EXACT_OUT was read as spending its path's last currency. Model: opus-5-5
This commit is contained in:
+341
-20
@@ -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,286 @@ 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();
|
||||
},
|
||||
);
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user