2 Commits
Author SHA1 Message Date
sneak 6fc42063d2 fix: a V4 step of chained swap actions shows what the user receives (closes #563)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A V4 step's input side is now the currency its SETTLE or SETTLE_ALL
names, and its output side the 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
2026-10-18 21:02:23 +00:00
clawbot 1f5ec1c8de fix: Send refuses every amount the confirmation screen would refuse (closes #532)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Send's Review accepted any amount parseFloat() read as above zero, so 1e-3, 1abc and amounts with too many decimal places reached ConfirmTx, which then refused them or failed to estimate the fee.

isValidAmount() in src/shared/txValidation.js is now the one amount check, used by Send's Review and Max and by validateTransfer() for ConfirmTx: plain digits with at most one decimal point, above zero, nothing but zeros past the token's places. Zeros past those places are accepted, since the transfer encodes the amount exactly. Send now reads the token's decimal places before the ENS lookup, because the check needs them.

Judgement call: ConfirmTx now also refuses zero, as Send already did.

Model: opus-5-5
2026-10-18 22:46:07 +02:00
9 changed files with 871 additions and 177 deletions
+40 -14
View File
@@ -1164,8 +1164,9 @@ read:
amount of one step that names an input token or amount: the last
`PERMIT2_PERMIT` step if there is one, otherwise the first V2 or V3 exact-in,
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
`amountInMax`, and a V4 swap step the `amountIn` of its first readable
exact-in action.
`amountInMax`, and a V4 swap step the `amountIn` of the first of its swap
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
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
The router reads that zero as "the pair already holds the input tokens": the
@@ -1186,17 +1187,36 @@ read:
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for
that side, or `Unknown (could not be decoded)` instead when a V4 swap action in
the transaction could not be read (see Transaction Decoding below). A V4 step
whose swap action could not be read still sets each side as a swap step does,
and a side its `SETTLE` or `TAKE` did not name is then unknown: no other step's
token or figure is shown for it. An `UNWRAP_WETH` step makes `Token Out` ETH
only when the output side is WETH, on mainnet or Sepolia, or when no step set
the output side; a WETH `Min. received` figure then reads in ETH. Otherwise
`Token Out` and `Min. received` keep the output side's own token and figure,
whether the swap was paid in ETH or in a token: a V2 exact-out swap that buys
USDC and then unwraps the WETH it did not spend shows USDC. The token permission
warning on the signature screen has its own amount wording, including `Unknown`;
the SignApproval section below describes it.
the transaction could not be read (see Transaction Decoding below). A V4 step's
input side is the currency its `SETTLE` or `SETTLE_ALL` names, and its output
side the currency its `TAKE` or `TAKE_ALL` names. A side none of them names
comes from the step's swap actions, lined up as the exact-out actions in reverse
order, then the exact-in actions in the order they appear: the input side from
the first action in that line, the output side 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` 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.
`2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
@@ -1686,7 +1706,13 @@ view would leave a wallet one click from deletion.
- **Transitions**:
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
- "Review" with an unresolvable ENS name or an invalid amount → flash
message, no screen change
message, no screen change. An amount is valid when it is a number above
zero, written as digits with at most one decimal point, with nothing but
zeros after the token's last decimal place. ETH has 18 places, and a token
with more, or with an unknown number, is held to 18. Zeros past those
places leave the amount unchanged, so `1.0000000` of a 6-place token
passes and is sent as exactly 1. ConfirmTx makes the same check before
anything can be sent
- "Back" → previous screen (Home, AddressDetail, or AddressToken)
#### ConfirmTx (`confirm-tx`)
+1
View File
@@ -278,6 +278,7 @@ function renderValidation(txInfo) {
const { canSend, codes } = validateTransfer({
isErc20,
amount: txInfo.amount,
tokenDecimals: txInfo.tokenDecimals,
ethBalance: txInfo.balance,
tokenBalance: txInfo.tokenBalance,
feeStatus,
+30 -21
View File
@@ -24,6 +24,7 @@ const {
} = require("../../shared/amountDisplay");
const {
feeReserveWei,
isValidAmount,
maxEthAmount,
maxTokenAmount,
} = require("../../shared/txValidation");
@@ -255,13 +256,18 @@ async function fillMaxAmount() {
const token = state.selectedToken || $("send-token").value;
if (token !== "ETH") {
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
const { tokenBalance: bal, tokenDecimals } = tokenBalanceAndDecimals(
addr,
token,
);
if (bal == null) {
showFlash("This token's balance is unknown.");
return;
}
const amount = maxTokenAmount(bal);
if (!(parseFloat(amount) > 0)) {
// The balance is stored at the token's own decimal places and cut to
// 18 above, so the only balance this refuses is zero.
if (!isValidAmount(amount, tokenDecimals)) {
showFlash("This token's balance is zero.");
return;
}
@@ -367,7 +373,28 @@ function init(_ctx) {
);
return;
}
if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
const token = state.selectedToken || $("send-token").value;
const addr = currentAddress();
let tokenSymbol = null;
let tokenBalance = null;
let tokenDecimals = null;
if (token !== "ETH") {
tokenSymbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
addr,
token,
));
}
// The confirmation screen's own amount check, so it is never shown
// an amount it would refuse.
if (!isValidAmount(amount, tokenDecimals)) {
showFlash("Please enter a valid amount.");
return;
}
@@ -391,24 +418,6 @@ function init(_ctx) {
}
}
const token = state.selectedToken || $("send-token").value;
const addr = currentAddress();
let tokenSymbol = null;
let tokenBalance = null;
let tokenDecimals = null;
if (token !== "ETH") {
tokenSymbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
addr,
token,
));
}
ctx.showConfirmTx({
from: addr.address,
to: resolvedTo,
+37 -11
View File
@@ -1,4 +1,5 @@
// Balance arithmetic for the Send and transaction confirmation screens.
// The amount check and balance arithmetic for the Send and transaction
// confirmation screens.
//
// Pure: no DOM, no network, no state. Everything is exact integer math on
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
@@ -20,7 +21,7 @@ const FEE_KNOWN = "known";
const FEE_UNAVAILABLE = "unavailable";
const CODES = {
// The amount is not a non-negative number we can do exact arithmetic on.
// The amount is one isValidAmount() refuses.
AMOUNT_INVALID: "amount-invalid",
// ERC-20: the token amount exceeds the token balance.
INSUFFICIENT_TOKEN: "insufficient-token",
@@ -87,6 +88,27 @@ function toFixedPoint(value) {
}
}
// Whether `amount` can be sent: a number above zero, written as digits with
// at most one decimal point ("0.5", "12", ".5"), with nothing but zeros after
// the token's last decimal place, `tokenDecimals`, and nothing but zeros after
// the 18th, the places the arithmetic here works in, which are ETH's. ETH, and
// a token whose decimal places are unknown, pass null and are held to the 18
// (the confirmation screen refuses to send such a token anyway). Zeros past
// those places are accepted because parseUnits() drops them here and again
// when the transfer is encoded, so "1.0000000" of a 6-place token is exactly
// 1 everywhere. Refuses "1e-3", "1abc", "0" and "-1". The Send screen checks
// it before the confirmation screen opens, and validateTransfer() checks it
// again there.
function isValidAmount(amount, tokenDecimals = null) {
if (typeof amount !== "string") return false;
const places = Math.min(tokenDecimals ?? SCALE_DECIMALS, SCALE_DECIMALS);
try {
return parseUnits(amount.trim(), places) > 0n;
} catch {
return false;
}
}
// The most ETH a send can carry: the exact balance minus the fee reserve from
// feeReserveWei(), as a decimal string, so validateTransfer() passes it with
// exactly that reserve left behind. `ethBalance` is the exact decimal string
@@ -112,9 +134,11 @@ function maxTokenAmount(tokenBalance) {
// Validate a pending transfer against the balances that must cover it.
//
// isErc20 — token transfer rather than a native ETH transfer
// amount — human decimal string being sent, non-negative. Anything
// else, a negative value included, is an unusable amount
// amount — human decimal string being sent. One isValidAmount()
// refuses, a negative value included, is an unusable amount
// rather than an amount that passes every comparison.
// tokenDecimals — ERC-20: the token's decimal places, or null when they
// are unknown. Passed to isValidAmount().
// ethBalance — human decimal string, the sender's ETH balance
// tokenBalance — human decimal string, the sender's token balance
// feeStatus — FEE_PENDING, FEE_KNOWN or FEE_UNAVAILABLE. Anything else
@@ -128,6 +152,7 @@ function maxTokenAmount(tokenBalance) {
function validateTransfer({
isErc20 = false,
amount,
tokenDecimals = null,
ethBalance,
tokenBalance,
feeStatus = FEE_PENDING,
@@ -135,17 +160,17 @@ function validateTransfer({
} = {}) {
const codes = [];
const amountFp = toFixedPoint(amount);
const ethFp = toFixedPoint(ethBalance) ?? 0n;
// A negative amount parses to a valid bigint, so every comparison below
// is trivially false and the send clears the screen — then dies at encode
// time in parseEther(). Unusable, on the same footing as a malformed fee.
if (amountFp === null || amountFp < 0n) {
// An amount refused here never reaches the comparisons below: a negative
// one would pass every one of them, then die at encode time in
// parseEther().
if (!isValidAmount(amount, isErc20 ? tokenDecimals : null)) {
codes.push(CODES.AMOUNT_INVALID);
return { canSend: false, codes };
}
const amountFp = toFixedPoint(amount);
const ethFp = toFixedPoint(ethBalance) ?? 0n;
// Fail closed. Anything that is not a usable fee under a recognised
// status — a malformed feeWei, or a status this module does not know —
// is an unavailable estimate, never a fee of zero. Every such input errs
@@ -195,6 +220,7 @@ module.exports = {
SCALE_DECIMALS,
feeReserveWei,
feeEstimateWei,
isValidAmount,
maxEthAmount,
maxTokenAmount,
toFixedPoint,
+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 = 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,116 @@ function decodeExactly(types, data) {
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.
// 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.
// SETTLE or SETTLE_ALL names the step's input currency, and TAKE or TAKE_ALL
// its output currency, wherever in the step they stand. A side neither one
// names is read from the swap actions, 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) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
@@ -330,11 +438,12 @@ function decodeV4Swap(input) {
let settleToken = null;
let takeToken = null;
let amountIn = null;
let amountOutMin = null;
// The step's swap actions, as decodeV4SwapAction() reads them, lined
// up as the comment above says.
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 +456,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 +467,31 @@ 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;
}
let swap = NO_SWAP_READ;
try {
swap = decodeV4SwapAction(actionId, params[i]);
} 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;
}
// An exact-out action goes in front of the swap actions
// before it, an exact-in one after them.
if (
actionId === V4_SWAP_EXACT_OUT_SINGLE ||
actionId === V4_SWAP_EXACT_OUT
) {
swaps.unshift(swap);
} 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;
}
swaps.push(swap);
}
}
} 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 {
tokenIn: settleToken,
tokenOut: takeToken,
amountIn,
amountOutMin,
tokenIn,
tokenOut,
amountIn: first.tokenIn === tokenIn ? first.amountIn : null,
amountOutMin: last.tokenOut === tokenOut ? last.amountOutMin : null,
swapUndecoded,
};
} catch {
@@ -597,11 +638,12 @@ function decode(data, toAddress, sources) {
if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]);
if (v4 && v4.swapUndecoded) {
// The unread swap action named this step's currencies
// and amounts, so the step is marked and still sets
// both sides as a swap step does. A side its SETTLE or
// TAKE did not name is set undetermined, not left to
// an earlier or later step's token and figure.
// A swap action of this step went unread, so the step
// is marked and still sets both sides as a swap step
// does. A currency or figure that neither SETTLE,
// SETTLE_ALL, TAKE, TAKE_ALL nor a readable swap
// action gave decodeV4Swap() is set undetermined, not
// left to an earlier or later step's token and figure.
swapUndecoded = true;
commandNames[i] += " (could not be decoded)";
if (!inputEstablished) {
+213
View File
@@ -0,0 +1,213 @@
// The Send screen refuses every amount the confirmation screen would refuse,
// by the same check (https://git.eeqj.de/sneak/AutistMask/issues/532).
//
// Send used to accept anything parseFloat() read as above zero, so "1e-3",
// "1abc" and an amount with more decimal places than the token has went on to
// a confirmation screen that then refused them. Both screens now call
// isValidAmount() in src/shared/txValidation.js.
//
// Driven through the real Send review handler and the real confirmation
// screen, with only the node and the DOM stubbed.
"use strict";
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: jest.fn(),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// What the confirmation screen asks the node for.
const mockProvider = {
getFeeData: async () => ({
maxFeePerGas: 2000000000n,
gasPrice: 1000000000n,
}),
estimateGas: async () => 21000n,
getCode: async () => "0x",
getTransactionCount: async () => 1,
getBalance: async () => 0n,
};
jest.mock("../src/shared/balances", () => {
const actual = jest.requireActual("../src/shared/balances");
return { ...actual, getProvider: () => mockProvider };
});
// The confirmation screen's Etherscan label lookup fails, as it does offline.
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
addEventListener(name, fn) {
handlers.set(name, fn);
},
appendChild(child) {
return child;
},
querySelectorAll: () => [],
querySelector: () => null,
remove() {},
focus() {},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
createElement: (tag) => makeEl(tag),
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator = { clipboard: { writeText() {} } };
const { state } = require("../src/shared/state");
const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx");
const HOLDER = "0x" + "a".repeat(40);
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// A token with 6 decimal places, as USDC has.
const TOKEN = "0x" + "d".repeat(40);
const TOKEN_BY_SYMBOL = { ETH: "ETH", TOK: TOKEN };
function el(id) {
return global.document.getElementById(id);
}
// Press Review on the Send screen with `amount` entered, for a send of `token`
// ("ETH" or TOKEN), and return what it handed the confirmation screen, or null
// when it handed nothing over.
async function review(amount, token) {
let handed = null;
send.init({ showConfirmTx: (info) => (handed = info) });
state.selectedToken = token;
el("send-to").value = RECIPIENT;
el("send-amount").value = amount;
await el("btn-send-review").handlers.get("click")();
return handed;
}
// Show the confirmation screen for the send Review hands over for 0.5, with
// `amount` in its place, and let its fee estimate settle.
async function confirm(amount, token) {
const txInfo = await review("0.5", token);
confirmTx.show({ ...txInfo, amount });
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
beforeEach(() => {
elements.clear();
state.wallets = [
{
name: "Wallet 1",
addresses: [
{
address: HOLDER,
balance: "1.0",
tokenBalances: [
{
address: TOKEN,
name: "Token",
symbol: "TOK",
decimals: 6,
balance: "250.0",
holders: 50000,
},
],
},
],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.trackedTokens = [
{ address: TOKEN, symbol: "TOK", name: "Token", decimals: 6 },
];
state.fraudContracts = [];
state.selectedToken = null;
state.currentView = null;
});
describe.each([
["1e-3", "ETH"],
["1e-3", "TOK"],
["1abc", "ETH"],
// 19 decimal places; ETH has 18.
["0.0000000000000000001", "ETH"],
// 7 decimal places; the token has 6.
["0.0000001", "TOK"],
["0", "ETH"],
["-1", "ETH"],
])("%s %s", (amount, symbol) => {
const token = TOKEN_BY_SYMBOL[symbol];
test("Send refuses it with a flash message and opens nothing", async () => {
expect(await review(amount, token)).toBe(null);
expect(el("flash-msg").textContent).toBe(
"Please enter a valid amount.",
);
});
test("the confirmation screen refuses it too", async () => {
await confirm(amount, token);
expect(el("confirm-errors").innerHTML).toContain(
"Please enter a valid amount to send.",
);
expect(el("btn-confirm-send").disabled).toBe(true);
});
});
describe.each([
["0.5", "ETH"],
["0.5", "TOK"],
// Exactly the 6 decimal places the token has.
["0.000001", "TOK"],
// Only zeros past the token's 6 places, and past ETH's 18.
["1.0000000", "TOK"],
["0.5000000000000000000", "ETH"],
])("%s %s", (amount, symbol) => {
const token = TOKEN_BY_SYMBOL[symbol];
test("Send hands it to the confirmation screen", async () => {
expect((await review(amount, token)).amount).toBe(amount);
expect(el("flash-msg").textContent).toBe("");
});
test("the confirmation screen lets it be sent", async () => {
await confirm(amount, token);
expect(el("confirm-errors").innerHTML).toBe("");
expect(el("btn-confirm-send").disabled).toBe(false);
});
});
+6
View File
@@ -116,6 +116,12 @@ describe("transferAmountUnits", () => {
expect(() => transferAmountUnits("0.0000001", 6, 6n)).toThrow();
});
test("encodes zeros past the token's scale as the amount typed", () => {
// The Send screen accepts "1.0000000" for a 6-place token, so the
// transfer must move exactly 1 of it.
expect(transferAmountUnits("1.0000000", 6, 6n)).toBe(1000000n);
});
test("every refusal is a full sentence", () => {
const messages = [];
for (const args of [
+65
View File
@@ -6,6 +6,7 @@ const {
FEE_UNAVAILABLE,
feeReserveWei,
feeEstimateWei,
isValidAmount,
maxEthAmount,
maxTokenAmount,
toFixedPoint,
@@ -33,6 +34,49 @@ describe("toFixedPoint", () => {
});
});
// The one amount check the Send screen and the confirmation screen share.
// The screens themselves are driven in tests/sendAmountRule.test.js.
describe("isValidAmount", () => {
test("accepts a number above zero written as digits and a decimal point", () => {
expect(isValidAmount("0.5")).toBe(true);
expect(isValidAmount("12")).toBe(true);
expect(isValidAmount(".5")).toBe(true);
expect(isValidAmount("0.000000000000000001")).toBe(true);
});
test("refuses an exponent, trailing text, zero and a negative amount", () => {
expect(isValidAmount("1e-3")).toBe(false);
expect(isValidAmount("1abc")).toBe(false);
expect(isValidAmount("0")).toBe(false);
expect(isValidAmount("0.0")).toBe(false);
expect(isValidAmount("-1")).toBe(false);
expect(isValidAmount("")).toBe(false);
expect(isValidAmount(undefined)).toBe(false);
});
test("refuses a digit other than 0 past the token's places", () => {
expect(isValidAmount("0.000001", 6)).toBe(true);
expect(isValidAmount("0.0000001", 6)).toBe(false);
expect(isValidAmount("5", 0)).toBe(true);
expect(isValidAmount("0.5", 0)).toBe(false);
});
test("accepts zeros past the token's places, which leave the amount unchanged", () => {
expect(isValidAmount("1.0000000", 6)).toBe(true);
expect(isValidAmount("5.0", 0)).toBe(true);
expect(isValidAmount("1.0000000000000000000")).toBe(true);
expect(isValidAmount("0.0000000", 6)).toBe(false);
});
test("holds ETH, a token of unknown places, and any token to 18 places", () => {
const nineteen = "0.0000000000000000001";
expect(isValidAmount(nineteen)).toBe(false);
expect(isValidAmount(nineteen, null)).toBe(false);
expect(isValidAmount(nineteen, 24)).toBe(false);
expect(isValidAmount("0.000000000000000001", 24)).toBe(true);
});
});
describe("validateTransfer, native ETH", () => {
const eth = (over) => ({
isErc20: false,
@@ -134,6 +178,17 @@ describe("validateTransfer, native ETH", () => {
).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
});
test("rejects a zero amount and an exponent", () => {
expect(validateTransfer(eth({ amount: "0" }))).toEqual({
canSend: false,
codes: [CODES.AMOUNT_INVALID],
});
expect(validateTransfer(eth({ amount: "1e-3" }))).toEqual({
canSend: false,
codes: [CODES.AMOUNT_INVALID],
});
});
test("treats a missing balance as zero, not as unlimited", () => {
const r = validateTransfer(eth({ ethBalance: undefined }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_ETH]);
@@ -205,6 +260,16 @@ describe("validateTransfer, ERC-20", () => {
expect(r).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
});
test("rejects more decimal places than the token has", () => {
const r = validateTransfer(
erc20({ amount: "0.0000001", tokenDecimals: 6 }),
);
expect(r).toEqual({ canSend: false, codes: [CODES.AMOUNT_INVALID] });
expect(
validateTransfer(erc20({ amount: "0.000001", tokenDecimals: 6 })),
).toEqual({ canSend: true, codes: [] });
});
test("treats a missing token balance as zero", () => {
const r = validateTransfer(erc20({ tokenBalance: undefined }));
expect(r.codes).toEqual([CODES.INSUFFICIENT_TOKEN]);
+326 -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) {
@@ -358,10 +382,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 +404,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 +438,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 +447,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 +464,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 +496,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 +1159,277 @@ describe("uniswap decoder", () => {
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
// exact-in reads an amountIn of zero as universal-router
// Constants.ALREADY_PAID: an earlier step sent the tokens to the pair, and