harden: pair every swap amount with the token that supplied it (closes #359)
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m47s
e2e / e2e-firefox (push) Successful in 37s

`src/shared/uniswap.js` gated the token and the amount on truthiness, and
gated them independently. An address is never falsy once set, but an amount
of `0n` is, so a hop supplying a zero amount fixed the token permanently
while leaving the amount open, and the next hop's figure was then displayed
against the first hop's token, at that token's scale.

Input side: a V3 `USDT -> WETH` hop with `amountIn = 0n` followed by a V2
`WETH -> USDC` hop of `0.5e18` rendered `Token In = USDT` with
`Amount = 500000000000.0000 USDT`. Output side: a V3 hop followed by a V4
step with `amountOutMin = 0n` kept `Min. received = 0.5000 WETH` on screen
for a final leg that guarantees nothing.

Both halves are the same gate in the same file and take the same remedy, so
they are one change rather than two statements of one rule.

- One `present()` helper replaces every truthiness gate on a decoded value.
- The input and output sides are each set as a PAIR, never field by field:
  an amount and the token it is counted in always come from the same hop.
  The input side is fixed by the first hop that states either half, the
  output side by the last. A half the establishing hop did not state stays
  null and the line says so.
- A zero slippage floor reads `None (no minimum guaranteed)` rather than
  `0.0000`, which reads as an artifact of the four-decimal rule.
- A V4 `amountIn` of zero is `ActionConstants.OPEN_DELTA` -- v4-periphery's
  `V4Router` substitutes the full open credit for it -- so it reads
  `All available (V4 open delta)`, not `0.0000`, which would have stated the
  exact inverse of what the step does. `amountOutMinimum` gets no such
  mapping and a zero there is a literal floor of zero.

Tests: the two fail-first cases from the issues, two further pairing cases
(a zero minimum against a named output token, and a final leg naming a token
but no minimum), the open-delta amount, and the PERMIT2_PERMIT-only
`execute()` that must invent no output token. Native ETH is pinned on both
sides against the real mainnet fixture and the WRAP_ETH/UNWRAP_WETH paths.

closes #364
This commit is contained in:
2026-08-23 18:34:03 +00:00
parent c9ebac822a
commit bbfbe885cd
3 changed files with 452 additions and 57 deletions

15
TODO.md
View File

@@ -45,6 +45,21 @@ but the review is broader than any of them.
# Completed Steps
- 2026-08-23: A swap amount and the token it is counted in now always come from
the same hop, on both sides of the approval screen
([#359](https://git.eeqj.de/sneak/AutistMask/issues/359) and
[#364](https://git.eeqj.de/sneak/AutistMask/issues/364), the output and input
halves of one gate, fixed as one unit). `src/shared/uniswap.js` gated the
token and the amount on truthiness and independently; an address is never
falsy once set but an amount of `0n` is, so a hop supplying a zero amount
fixed the token and left the amount open, and the next hop's figure was then
rendered against the first hop's token at that token's scale — 0.5 WETH shown
as `500000000000.0000 USDT`, and an earlier hop's `Min. received` shown for a
final leg that guarantees nothing. Both sides are now set as a pair through
explicit presence, a zero slippage floor reads `None (no minimum guaranteed)`,
and V4's `OPEN_DELTA` (an `amountIn` of zero, which `V4Router` reads as "swap
the whole open credit") reads `All available (V4 open delta)` instead of
`0.0000`.
- 2026-08-23: A swap whose input token the calldata never named is said to be
unknown instead of being called ETH
([#357](https://git.eeqj.de/sneak/AutistMask/issues/357)), the twin on the

View File

@@ -53,6 +53,53 @@ function formatAmount(raw, decimals) {
// on a scale — not as a token name, and not as a quantity.
const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// Explicit presence, never truthiness. Every gate in this file that guards a
// decoded value goes through here: an address is never falsy once set, but an
// amount of 0n is, and a gate that cannot tell a genuine zero from an absent
// value is the trap this decoder has now been bitten by five times.
function present(value) {
return value !== null && value !== undefined;
}
// Uniswap V4 spells "use the whole open delta" as an amount of zero:
// v4-periphery `src/libraries/ActionConstants.sol` declares
// `uint128 internal constant OPEN_DELTA = 0` ("used to signal that an action
// should use the input value of the open delta on the pool manager or of the
// balance that the contract holds"), and `src/V4Router.sol` substitutes the
// full open credit whenever an exact-in swap action's `amountIn` equals it:
//
// uint128 amountIn = params.amountIn;
// if (amountIn == ActionConstants.OPEN_DELTA) {
// amountIn = _getFullCredit(...).toUint128();
// }
//
// in both `_swapExactInputSingle` and `_swapExactInput`. Sentinel and literal
// zero are the same uint128 word, so the encoding CANNOT distinguish them —
// and the router does not try: it reads every zero as the sentinel, so in V4
// there is no such thing as an exact-in swap of literally zero. The amount is
// therefore not stated by the calldata at all; it is whatever credit is open
// at execution time. It is carried as this sentinel rather than as 0n because
// printing "0.0000" would state the exact inverse of what will happen —
// "nothing is being swapped" for a step that swaps the entire balance.
//
// `amountOutMinimum` gets no such mapping: V4Router compares it directly
// (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so
// a zero minimum is a literal zero slippage floor and is stated as one. Nor do
// the V2/V3 paths have it — universal-router's `V3SwapRouter.v3SwapExactInput`
// special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero —
// so a zero `amountIn` there is a literal zero and is displayed as one.
const OPEN_DELTA = Symbol("v4-open-delta");
// The two amount lines that state a fact instead of a quantity. Same register
// as UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread
// as a number — and deliberately not a third phrasing of "not named": these
// say different things.
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
const NO_MINIMUM = "None (no minimum guaranteed)";
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
// `decimals` is null when nothing knows this token's scale. It is not
// defaulted to 18: the swap lines land on the same approval screen as the
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
@@ -224,6 +271,14 @@ const V4_SWAP_EXACT_OUT = 0x09;
const V4_SETTLE = 0x0b;
const V4_TAKE = 0x0e;
// 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
// comment above. The exact-OUT actions below decode no amounts at all, so
// their own OPEN_DELTA mapping on `amountOut` never reaches the screen.
function v4ExactInAmount(raw) {
return raw === 0n ? OPEN_DELTA : raw;
}
// 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,
@@ -272,13 +327,17 @@ function decodeV4Swap(input) {
],
params[i],
);
if (!settleToken) settleToken = s[0][0];
if (!present(settleToken)) settleToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !takeToken) {
if (path.length > 0 && !present(takeToken)) {
takeToken = path[path.length - 1][0];
}
if (!amountIn) amountIn = s[0][2];
if (!amountOutMin) amountOutMin = s[0][3];
if (!present(amountIn)) {
amountIn = v4ExactInAmount(s[0][2]);
}
if (!present(amountOutMin)) {
amountOutMin = s[0][3];
}
} catch {
// Fall through — SETTLE/TAKE will provide tokens
}
@@ -294,16 +353,20 @@ function decodeV4Swap(input) {
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!settleToken)
if (!present(settleToken))
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!takeToken)
if (!present(takeToken))
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
if (!amountIn) amountIn = s[0][2];
if (!amountOutMin) amountOutMin = s[0][3];
if (!present(amountIn)) {
amountIn = v4ExactInAmount(s[0][2]);
}
if (!present(amountOutMin)) {
amountOutMin = s[0][3];
}
} catch {
// Fall through
}
@@ -320,9 +383,9 @@ function decodeV4Swap(input) {
],
params[i],
);
if (!takeToken) takeToken = s[0][0];
if (!present(takeToken)) takeToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !settleToken) {
if (path.length > 0 && !present(settleToken)) {
settleToken = path[path.length - 1][0];
}
} catch {
@@ -338,11 +401,11 @@ function decodeV4Swap(input) {
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!settleToken)
if (!present(settleToken))
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!takeToken)
if (!present(takeToken))
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
@@ -381,11 +444,44 @@ function decode(data, toAddress, sources) {
let inputToken = null;
let inputAmount = null;
let inputEstablished = false;
let outputToken = null;
let minOutput = null;
let hasUnwrapWeth = false;
const commandNames = [];
// THE INVARIANT: an amount and the token it is counted in always come
// from the same hop. A figure is never rendered against a token that
// did not supply it.
//
// Both sides are therefore set as a PAIR — never field by field, and
// never on truthiness. An address is never falsy once set but an
// amount of 0n is, so gating the two halves independently let a hop
// with a zero amount fix the token and leave the amount open; the next
// hop's figure was then displayed against the first hop's token, at the
// first hop's scale. A V3 USDT->WETH hop with amountIn 0 followed by a
// V2 WETH->USDC hop of 0.5e18 rendered "500000000000.0000 USDT".
//
// 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.
const setInput = (token, amount) => {
inputToken = present(token) ? token : null;
inputAmount = present(amount) ? amount : null;
inputEstablished = true;
};
const setInputOnce = (token, amount) => {
if (inputEstablished) return;
if (!present(token) && !present(amount)) return;
setInput(token, amount);
};
const setOutput = (token, amount) => {
if (!present(token) && !present(amount)) return;
outputToken = present(token) ? token : null;
minOutput = present(amount) ? amount : null;
};
for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & 0x1f;
commandNames.push(
@@ -396,69 +492,61 @@ function decode(data, toAddress, sources) {
try {
if (cmdId === 0x0a) {
const p = decodePermit2(inputs[i]);
if (p) {
inputToken = p.token;
inputAmount = p.amount;
}
// A permit states both halves itself, so it may replace an
// input side an earlier hop established without breaking
// the invariant.
if (p) setInput(p.token, p.amount);
}
if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]);
if (b) {
outputToken = b.token;
minOutput = b.minBalance;
}
if (b) setOutput(b.token, b.minBalance);
}
if (cmdId === 0x00) {
const s = decodeV3SwapExactIn(inputs[i]);
if (s) {
if (!inputToken) inputToken = s.tokenIn;
if (!inputAmount) inputAmount = s.amountIn;
setInputOnce(s.tokenIn, s.amountIn);
// Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output token
// and minimum received amount.
outputToken = s.tokenOut;
minOutput = s.amountOutMin;
setOutput(s.tokenOut, s.amountOutMin);
}
}
if (cmdId === 0x08) {
const s = decodeV2SwapExactIn(inputs[i]);
if (s) {
if (!inputToken) inputToken = s.tokenIn;
if (!inputAmount) inputAmount = s.amountIn;
outputToken = s.tokenOut;
minOutput = s.amountOutMin;
setInputOnce(s.tokenIn, s.amountIn);
setOutput(s.tokenOut, s.amountOutMin);
}
}
if (cmdId === 0x0b) {
const w = decodeWrapEth(inputs[i]);
if (w && !inputToken) {
inputToken =
"0x0000000000000000000000000000000000000000";
inputAmount = w.amount;
if (w) {
setInputOnce(
"0x0000000000000000000000000000000000000000",
w.amount,
);
}
}
if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]);
if (v4) {
if (!inputToken && v4.tokenIn) inputToken = v4.tokenIn;
if (!inputAmount && v4.amountIn)
inputAmount = v4.amountIn;
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.
if (v4.tokenOut) {
outputToken = v4.tokenOut;
} else if (v4.amountOutMin) {
outputToken = null;
}
if (v4.amountOutMin) minOutput = v4.amountOutMin;
// 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);
}
}
@@ -495,7 +583,7 @@ function decode(data, toAddress, sources) {
address: toAddress,
});
if (inputToken && inInfo.address) {
if (present(inputToken) && present(inInfo.address)) {
const label = inSymbol
? inSymbol + " (" + inputToken + ")"
: inputToken;
@@ -515,16 +603,20 @@ function decode(data, toAddress, sources) {
details.push({ label: "Token In", value: UNNAMED_CURRENCY });
}
if (inputAmount !== null && inputAmount !== undefined) {
const maxUint160 = BigInt(
"0xffffffffffffffffffffffffffffffffffffffff",
);
const isUnlimited = inputAmount >= maxUint160;
// An unbounded permit needs no scale to describe, so it is still
// named rather than refused.
const amount = isUnlimited
? { raw: "Unlimited", display: "Unlimited" }
: amountText(inputAmount, inInfo);
if (present(inputAmount)) {
// Two amounts need no scale to describe and are named rather than
// formatted: V4's open delta, which is not a quantity at all (see
// OPEN_DELTA), and an unbounded permit. The open-delta test comes
// first — the sentinel is not a bigint and cannot be compared with
// one.
let amount;
if (inputAmount === OPEN_DELTA) {
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
} else if (inputAmount >= MAX_UINT160) {
amount = { raw: "Unlimited", display: "Unlimited" };
} else {
amount = amountText(inputAmount, inInfo);
}
details.push({
label: "Amount",
value: amount.display,
@@ -537,7 +629,7 @@ function decode(data, toAddress, sources) {
// entirely, leaving a Min. received figure with nothing saying what is
// being received. The Token In line above already falls back to the
// address; this does the same.
if (outInfo.address) {
if (present(outInfo.address)) {
const label = outSymbol
? outSymbol + " (" + outInfo.address + ")"
: outInfo.address;
@@ -557,10 +649,19 @@ function decode(data, toAddress, sources) {
details.push({ label: "Token Out", value: UNNAMED_CURRENCY });
}
if (minOutput !== null && minOutput !== undefined) {
if (present(minOutput)) {
// A zero floor is the one case the user most needs stated: the
// swap guarantees nothing back. It is said in words, in the same
// register as UNNAMED_CURRENCY, because "0.0000 WETH" reads as an
// artifact of the four-decimal rule rather than as "this may
// return nothing" — and because it is true at every scale, so it
// holds even when the output token's decimals are unknown.
details.push({
label: "Min. received",
value: amountText(minOutput, outInfo).display,
value:
minOutput === 0n
? NO_MINIMUM
: amountText(minOutput, outInfo).display,
});
}

View File

@@ -94,6 +94,69 @@ function encodeV4Swap(actions, params) {
return coder.encode(["bytes", "bytes[]"], [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_SETTLE_ID = 0x0b;
const V4_TAKE_ID = 0x0e;
const ZERO_ADDR = "0x0000000000000000000000000000000000000000";
// Helper: V4 SETTLE params — (address currency, uint256 maxAmount, bool payerIsUser)
function encodeV4Settle(currency) {
return coder.encode(["address", "uint256", "bool"], [currency, 0n, true]);
}
// Helper: V4 TAKE params — (address currency, address recipient, uint256 amount)
function encodeV4Take(currency) {
return coder.encode(
["address", "address", "uint256"],
[currency, USER_ADDR, 0n],
);
}
// Helper: V4 ExactInputParams — (address currencyIn,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountIn, uint128 amountOutMin)
function encodeV4ExactIn(currencyIn, pathTokens, amountIn, amountOutMin) {
return coder.encode(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
[
[
currencyIn,
pathTokens.map((t) => [t, 3000, 60, ZERO_ADDR, "0x"]),
amountIn,
amountOutMin,
],
],
);
}
// Helper: V4 ExactInputSingleParams —
// (tuple(address,address,uint24,int24,address) poolKey, bool zeroForOne,
// uint128 amountIn, uint128 amountOutMin, bytes hookData)
function encodeV4ExactInSingle(currency0, currency1, amountIn, amountOutMin) {
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
amountIn,
amountOutMin,
"0x",
],
],
);
}
function detail(result, label) {
return result.details.find((d) => d.label === label);
}
describe("uniswap decoder", () => {
test("returns null for non-execute calldata", () => {
expect(uniswap.decode("0x", ROUTER_ADDR)).toBeNull();
@@ -118,6 +181,18 @@ describe("uniswap decoder", () => {
expect(tokenIn.value).toContain("USDT");
expect(tokenIn.address.toLowerCase()).toBe(USDT_ADDR.toLowerCase());
// Genuine native ETH on the output side, on a real mainnet fixture:
// V4's TAKE names it as Currency.wrap(address(0)), which reaches the
// decoder as the explicit zero address and must still read as ETH.
const tokenOut = result.details.find((d) => d.label === "Token Out");
expect(tokenOut.value).toBe("ETH");
expect(result.details.find((d) => d.label === "Amount").value).toBe(
"Unlimited",
);
expect(
result.details.find((d) => d.label === "Min. received").value,
).toBe("0.0002 ETH");
const steps = result.details.find((d) => d.label === "Steps");
expect(steps.value).toContain("Permit2 Permit");
expect(steps.value).toContain("V4 Swap");
@@ -181,8 +256,7 @@ describe("uniswap decoder", () => {
expect(tokenIn.value).toBe("ETH (native)");
const amount = result.details.find((d) => d.label === "Amount");
expect(amount.value).toContain("1.0000");
expect(amount.value).toContain("ETH");
expect(amount.value).toBe("1.0000 ETH");
});
test("decodes UNWRAP_WETH as ETH output", () => {
@@ -338,6 +412,211 @@ describe("uniswap decoder", () => {
expect(steps.value).toContain("V4 Swap");
});
// https://git.eeqj.de/sneak/AutistMask/issues/364 — the input half.
//
// Fails against c9ebac8: `if (!inputAmount) inputAmount = s.amountIn`
// cannot tell the V3 hop's genuine 0n from "not yet set", so the V2 hop's
// 0.5 WETH overwrote it while Token In stayed pinned to the V3 hop's USDT.
// Observed there: Amount = "500000000000.0000 USDT".
test("a hop's zero amountIn is a real amount, not an opening for the next hop's figure", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x08]),
[
encodeV3SwapExactIn(USER_ADDR, 0n, 0n, [USDT_ADDR, WETH_ADDR]),
encodeV2SwapExactIn(
USER_ADDR,
500000000000000000n, // 0.5 WETH
1000000n,
[WETH_ADDR, USDC_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
// The amount and the token it is counted in come from the same hop.
expect(detail(result, "Token In").address.toLowerCase()).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Amount").value).toBe("0.0000 USDT");
expect(detail(result, "Amount").value).not.toContain("500000000000");
});
// https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in
// the shape the issue measured: a V4 step that states a minimum of zero
// and names no output currency.
//
// Fails against c9ebac8: `if (v4.amountOutMin) minOutput = ...` and the
// `else if` beside it both read 0n as absent, so neither the figure nor
// the token moved. Observed there: Token Out = "WETH (0xC02aaA39...)" and
// Min. received = "0.5000 WETH" — the V3 hop's guarantee shown for a
// transaction whose final leg guarantees nothing.
test("a V4 step with a zero amountOutMin states no minimum instead of keeping an earlier hop's", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(USER_ADDR, 2000000n, 500000000000000000n, [
USDT_ADDR,
WETH_ADDR,
]),
encodeV4Swap(new Uint8Array([V4_SWAP_EXACT_IN]), [
encodeV4ExactIn(
WETH_ADDR,
[], // no path: this step names no output currency
1000000000000000000n,
0n, // no slippage floor at all
),
]),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Min. received").value).toBe(
"None (no minimum guaranteed)",
);
expect(detail(result, "Min. received").value).not.toContain("0.5000");
});
// The same zero floor, but with the final leg's output currency named:
// the figure must belong to the token beside it. Against c9ebac8 this
// rendered Token Out = USDC with Min. received = "500000000000.0000 USDC",
// the V3 hop's 0.5e18 WETH figure re-scaled to USDC's six decimals.
test("a zero minimum is stated against the token that supplied it", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(USER_ADDR, 2000000n, 500000000000000000n, [
USDT_ADDR,
WETH_ADDR,
]),
encodeV4Swap(
new Uint8Array([
V4_SETTLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ID,
]),
[
encodeV4Settle(WETH_ADDR),
encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
1000000000000000000n,
0n,
),
encodeV4Take(USDC_ADDR),
],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC");
expect(detail(result, "Min. received").value).toBe(
"None (no minimum guaranteed)",
);
});
// The other half of the same invariant: a final leg that names an output
// currency but no minimum leaves Min. received unstated. Against c9ebac8
// the V3 hop's figure stayed on screen beside the new token, rendering
// "500000000000.0000 USDC".
test("a final leg with no minimum drops the line rather than keeping an earlier hop's figure", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(USER_ADDR, 2000000n, 500000000000000000n, [
USDT_ADDR,
WETH_ADDR,
]),
encodeV4Swap(new Uint8Array([V4_SETTLE_ID, V4_TAKE_ID]), [
encodeV4Settle(WETH_ADDR),
encodeV4Take(USDC_ADDR),
]),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC");
expect(detail(result, "Min. received")).toBeUndefined();
});
// V4 spells "swap the whole open delta" as an amountIn of zero
// (v4-periphery ActionConstants.OPEN_DELTA = 0, applied by V4Router's
// _swapExactInputSingle / _swapExactInput). It is not a quantity, and
// printing "0.0000 WETH" for it would state the exact inverse of what the
// step does. Against c9ebac8 the Amount line was omitted entirely.
test("a V4 open-delta amountIn is named, not printed as zero", () => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([
V4_SETTLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ID,
]),
[
encodeV4Settle(WETH_ADDR),
encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
0n, // ActionConstants.OPEN_DELTA
990000n,
),
encodeV4Take(USDC_ADDR),
],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token In").value).toContain("WETH");
expect(detail(result, "Amount").value).toBe(
"All available (V4 open delta)",
);
expect(detail(result, "Min. received").value).toBe("0.9900 USDC");
});
// Pins what https://git.eeqj.de/sneak/AutistMask/pulls/356 changed without
// testing: a non-swap execute() carrying only PERMIT2_PERMIT names no
// output currency, so it says so and titles itself "Uniswap Swap" rather
// than inventing "Token Out: ETH".
test("a PERMIT2_PERMIT-only execute() invents no output token", () => {
const data = buildExecute(
"0x0a",
[encodePermit2(USDT_ADDR, 5000000n, ROUTER_ADDR)],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toContain("USDT");
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Token Out").address).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
});
test("handles unknown tokens gracefully", () => {
const fakeToken = "0x1111111111111111111111111111111111111111";
const data = buildExecute(