1 Commits
Author SHA1 Message Date
sneak 1af7c32b02 fix: any token a swap leaves in the router keeps it from reading as received (closes #606)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The decoder checked only the swap's output for being left in the router.
It now records every token a step pays the router, the WETH of a
WRAP_ETH and the rest of a V4 SETTLE taken to the router among them, and
any token still there after the last step gets a Paid to line naming it,
so the minimum reads Min. paid out. A swap step or V4 SETTLE that spends
all the router holds of a token, a WRAP_ETH of all its ETH, and swap
steps from the router that together spend what a WRAP_ETH stated count
as spending it, so Uniswap's own routes read as before.

Model: opus-5-5
2026-10-19 16:32:20 +00:00
4 changed files with 534 additions and 78 deletions
+51 -39
View File
@@ -2355,7 +2355,7 @@ view would leave a wallet one click from deletion.
transaction"
- Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline, and unless a swap pays only the From address, a
`Paid to` line for each other address it pays, or saying that its output
`Paid to` line for each other address it pays, or saying that a token
stays in the router or who it pays is unknown (see Transaction Decoding)
- From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if
@@ -3048,49 +3048,61 @@ Currently supported:
such as one whose owner word has a nonzero top byte (the router reads the
owner from the word's last 20 bytes), likewise sets neither, and it is listed
as `Balance Check (could not be decoded)`. When a swap step, `TAKE` or
`TAKE_PORTION` pays the router the swap's output token, the output stays in
the router unless a later step pays all the router holds of it to anyone but
the router: a `SWEEP` of that token, an `UNWRAP_WETH` when it is WETH, a
`TRANSFER` of `CONTRACT_BALANCE`, a `PAY_PORTION` of 10,000 bips or a
`PAY_PORTION_FULL_PRECISION` of 1e18 of that token, or an
`ACROSS_V4_DEPOSIT_V3` of `CONTRACT_BALANCE` of that token, or of ETH when it
sets `useNative`. The router sends ETH as written, so a `TRANSFER` of
`CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A step paying a
token to the V2 pair that the very next step spends, as a V2 exact-in of that
token whose `amountIn` is `ALREADY_PAID` (zero) or `CONTRACT_BALANCE`, is a
hop, not a payout: Uniswap's mixed routes pay the next V2 pair directly, and
the router enforces that swap's own minimum. The decoder checks that the
address paid is that pair, deriving it from the V2 factory and pair init code
hash the network's routers were deployed with and the first two tokens of that
step's path; a payment to any other address is named. (Sepolia's
`UniversalRouterV1_2_V2Support` was deployed with placeholders for both
values, so no V2 swap step can succeed on that router.) Every other payment
counts, in whichever step and of whatever token, a share of the output
included: an interface fee paid to another address by `PAY_PORTION`,
`TRANSFER` or `TAKE_PORTION` is named, and the minimum reads `Min. paid out`,
because the swap's minimum includes the fee. The minimum reads `Min. received`
only when some step pays the transaction's sender (the From address on
TxApproval, the sender the explorer reports on TransactionDetail) and no
`Paid to` line is shown. `Paid to` lines after `Token Out` name each other
address paid; one reads
`TAKE_PORTION` pays the router any token, the swap's output or another, a
`WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step
paid it into ETH, that token stays in the router unless a later step pays out
or spends all the router holds of it: a `SWEEP` of that token to anyone but
the router, an `UNWRAP_WETH` when it is WETH, a `WRAP_ETH` of
`CONTRACT_BALANCE` when it is ETH, a `TRANSFER` of `CONTRACT_BALANCE`, a
`PAY_PORTION` of 10,000 bips or a `PAY_PORTION_FULL_PRECISION` of 1e18 of that
token to anyone but the router, an `ACROSS_V4_DEPOSIT_V3` of
`CONTRACT_BALANCE` of that token, or of ETH when it sets `useNative`, or a V2
or V3 exact-in swap step or V4 `SETTLE` that pays `CONTRACT_BALANCE` of that
token from the router (`payerIsUser` false). Such steps also spend all the
WETH a `WRAP_ETH` paid the router once together they spend the amount the
`WRAP_ETH` states, as a swap paid in ETH does; a `SETTLE` of `OPEN_DELTA`
(zero) counts as spending what its step's swap spends. So the rest of a V4
`SETTLE` of more than its swap spends, taken to the router, the WETH of a
`WRAP_ETH`, and a token a swap pays the router on the way to the output, each
stays in the router when later steps do not spend all of it, where anyone can
take it in a later transaction. The router sends ETH as written, so a
`TRANSFER` of `CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A
step paying a token to the V2 pair that the very next step spends, as a V2
exact-in of that token whose `amountIn` is `ALREADY_PAID` (zero) or
`CONTRACT_BALANCE`, is a hop, not a payout: Uniswap's mixed routes pay the
next V2 pair directly, and the router enforces that swap's own minimum. The
decoder checks that the address paid is that pair, deriving it from the V2
factory and pair init code hash the network's routers were deployed with and
the first two tokens of that step's path; a payment to any other address is
named. (Sepolia's `UniversalRouterV1_2_V2Support` was deployed with
placeholders for both values, so no V2 swap step can succeed on that router.)
Every other payment counts, in whichever step and of whatever token, a share
of the output included: an interface fee paid to another address by
`PAY_PORTION`, `TRANSFER` or `TAKE_PORTION` is named, and the minimum reads
`Min. paid out`, because the swap's minimum includes the fee. The minimum
reads `Min. received` only when some step pays the transaction's sender (the
From address on TxApproval, the sender the explorer reports on
TransactionDetail) and no `Paid to` line is shown. `Paid to` lines after
`Token Out` name each other address paid; one reads
`The bridge, which pays the deposit's recipient on another network` for a
bridge deposit; one reads, for example,
`The router, 2000.0000 USDT from the sender (later steps may not spend all of it)`
for each move into the router, followed by a `Token moved into the router`
line that gives the moved token's address as the `Token In` line does; one
reads `The router, where it stays (no later step pays it out)` when the output
stays in the router; and one more reads `Unknown (could not be decoded)` when
a step that may pay an address went unread. When no step pays anyone, a single
line reads `Unknown (not named in the calldata)`. Wherever there is a
`Paid to` line, the minimum reads `Min. paid out`. A step goes unread when its
recipient does not decode as an address, or when it is a command the decoder
does not read: every command other than `PERMIT2_PERMIT`,
`PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
`V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`,
`PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`,
`UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`,
`PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and the NFT commands among
them), or a type its release names no command for.
reads `The router, where it stays (no later step pays it out)` for each token
that stays in the router, followed, for any token but the output, by a
`Token moved into the router` line giving that token in the same way (ETH by
name); and one more reads `Unknown (could not be decoded)` when a step that
may pay an address went unread. When no step pays anyone, a single line reads
`Unknown (not named in the calldata)`. Wherever there is a `Paid to` line, the
minimum reads `Min. paid out`. A step goes unread when its recipient does not
decode as an address, or when it is a command the decoder does not read: every
command other than `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`,
`V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`,
`SWEEP`, `TRANSFER`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`,
`ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20`
(`V3_SWAP_EXACT_OUT`, `PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and
the NFT commands among them), or a type its release names no command for.
### Non-Goals Forever
+149 -35
View File
@@ -5,6 +5,7 @@ const {
Interface,
AbiCoder,
getBytes,
getAddress,
formatUnits,
getCreate2Address,
keccak256,
@@ -337,8 +338,8 @@ const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// address this decoder did not read.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
// The Paid to line when the swap's output is paid to the router and no later
// step pays it out of the router.
// The Paid to line for each token paid to the router, the swap's output or
// another, that no later step pays out of the router.
const STAYS_IN_ROUTER =
"The router, where it stays (no later step pays it out)";
@@ -413,12 +414,16 @@ const NO_MINIMUM = "None (no minimum guaranteed)";
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
// WETH, the token UNWRAP_WETH turns into ETH: on mainnet, then on Sepolia.
// Either one counts on either network.
const WETH_ADDRESSES = [
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
];
// WETH, the token WRAP_ETH turns ETH into and UNWRAP_WETH turns into ETH, on
// each network, by chain id: `weth9` in the deployment parameters
// V2_FACTORIES cites, which every router in ROUTER_ADDRESSES was deployed
// with (their constructor arguments on the explorer).
const WETH = new Map([
[NETWORKS.mainnet.chainId, "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"],
[NETWORKS.sepolia.chainId, "0xfff9976782d46cc05630d1f6ebab18b2324d6b14"],
]);
// Either one counts on either network as the WETH UNWRAP_WETH turns into ETH.
const WETH_ADDRESSES = [...WETH.values()];
// Two recipient values the router reads as placeholders, not as addresses:
// MSG_SENDER pays the caller of execute(), which is the transaction's sender,
@@ -522,6 +527,7 @@ function decodeBalanceCheck(input) {
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
// address[] path, bool payerIsUser)
// A zero `amountIn` is read the way the router reads it, as ALREADY_PAID.
// With `payerIsUser` false the router pays the input from its own balance.
function decodeV2SwapExactIn(input) {
try {
const d = coder.decode(
@@ -535,6 +541,7 @@ function decodeV2SwapExactIn(input) {
path: d[3],
tokenIn: d[3][0],
tokenOut: d[3][d[3].length - 1],
payerIsUser: d[4],
};
} catch {
return null;
@@ -574,6 +581,7 @@ function decodeV3Path(pathHex) {
// Decode V3_SWAP_EXACT_IN (command 0x00) input bytes.
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
// bytes path, bool payerIsUser)
// With `payerIsUser` false the router pays the input from its own balance.
function decodeV3SwapExactIn(input) {
try {
const d = coder.decode(
@@ -588,6 +596,7 @@ function decodeV3SwapExactIn(input) {
amountOutMin: d[2],
tokenIn: path.tokenIn,
tokenOut: path.tokenOut,
payerIsUser: d[4],
};
} catch {
return null;
@@ -877,7 +886,8 @@ function totalAmount(amounts) {
// and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes
// a share of the currency, so it does not name the output side as TAKE does.
// `recipientUnread` is set when the step has a payout this decoder did not
// read.
// read. `settledFromRouter` is every SETTLE that the router pays from its own
// balance (`payerIsUser` false), as the currency and the amount as written.
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
@@ -887,6 +897,7 @@ function decodeV4Swap(input) {
let settleToken = null;
let takeToken = null;
const takes = [];
const settledFromRouter = [];
let recipientUnread = false;
// The step's swap actions that could be read, as decodeV4SwapAction()
// reads them.
@@ -906,6 +917,12 @@ function decodeV4Swap(input) {
params[i],
);
settleToken = s[0];
if (!s[2]) {
settledFromRouter.push({
currency: s[0],
amount: s[1],
});
}
} else if (actionId === V4_SETTLE_ALL) {
// SETTLE_ALL: (address currency, uint256 maxAmount)
const s = coder.decode(["address", "uint256"], params[i]);
@@ -966,6 +983,7 @@ function decodeV4Swap(input) {
amountIn: null,
amountOutMin: null,
takes,
settledFromRouter,
recipientUnread,
swapUndecoded,
hasSwapAction: true,
@@ -1007,6 +1025,7 @@ function decodeV4Swap(input) {
amountIn,
amountOutMin,
takes,
settledFromRouter,
recipientUnread,
swapUndecoded,
hasSwapAction: swaps.length > 0,
@@ -1099,16 +1118,43 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// router, as { token, amount }. Later steps may leave part of it
// there: nothing here checks that they spend all of it.
const movedIntoRouter = [];
// The tokens, lowercased, that a swap step or a V4 TAKE or
// TAKE_PORTION paid to the router and no later step paid out of it in
// full: a SWEEP of that token, an UNWRAP_WETH of WETH, or a TRANSFER,
// PAY_PORTION, PAY_PORTION_FULL_PRECISION or ACROSS_V4_DEPOSIT_V3 of
// all the router holds of that token.
const heldByRouter = new Set();
// Each token a step paid to the router that no later step paid out
// or spent in full, by its address, lowercased, ETH as the zero
// address. The router is paid what a swap step, V4 TAKE or
// TAKE_PORTION pays it, the WETH of a WRAP_ETH into it, and the ETH
// of an UNWRAP_WETH into it, in place of the WETH it held. All the
// router holds of a token is paid out or spent by a SWEEP of that
// token, an UNWRAP_WETH of WETH, a WRAP_ETH of CONTRACT_BALANCE of
// ETH, a TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION or
// ACROSS_V4_DEPOSIT_V3 of all of it, or a swap step or V4 SETTLE paid
// from the router (see spendFromRouter()). The value is the amount the
// router holds, in base units, when a WRAP_ETH stated it, otherwise
// null.
const heldByRouter = new Map();
const isRouter = (recipient) => {
const r = recipient.toLowerCase();
return r === ADDRESS_THIS || r === toAddress.toLowerCase();
};
// A swap step or V4 SETTLE pays `amount` of `token` from the router's
// own balance. CONTRACT_BALANCE pays all of it. Another amount the
// step states counts against the amount a WRAP_ETH stated, and the
// steps pay all of it once their amounts reach that one: a swap paid
// in ETH wraps what its swap steps then spend. An amount the step
// does not state, or one of a token whose amount is not known, pays
// out nothing this decoder can see.
const spendFromRouter = (token, amount) => {
const t = token.toLowerCase();
const held = heldByRouter.get(t);
if (amount === CONTRACT_BALANCE) {
heldByRouter.delete(t);
} else if (typeof held === "bigint" && typeof amount === "bigint") {
if (amount >= held) {
heldByRouter.delete(t);
} else {
heldByRouter.set(t, held - amount);
}
}
};
// A step pays `recipient`, an address other than the sender's or the
// router's, as written.
const addPaidElsewhere = (recipient) => {
@@ -1122,7 +1168,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const addPayee = (recipient, output) => {
const r = recipient.toLowerCase();
if (isRouter(recipient)) {
if (present(output)) heldByRouter.add(output.toLowerCase());
if (present(output)) {
heldByRouter.set(output.toLowerCase(), null);
}
} else if (r === MSG_SENDER || r === sender) {
paidToSender = true;
} else {
@@ -1243,6 +1291,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// the last swap step determines the final output token
// and minimum received amount.
setOutput(s.tokenOut, s.amountOutMin);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn);
}
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
}
@@ -1254,6 +1305,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
setInputOnce(s.tokenIn, s.amountIn);
setOutput(s.tokenOut, s.amountOutMin);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn);
}
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
}
@@ -1350,12 +1404,45 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
w.amount,
);
addPayee(w.recipient);
// CONTRACT_BALANCE wraps all the router's ETH.
if (w.amount === CONTRACT_BALANCE) {
heldByRouter.delete(
"0x0000000000000000000000000000000000000000",
);
}
// Into the router, the router holds the WETH. Its
// amount is known when the step states it and the
// router held no WETH before.
if (isRouter(w.recipient) && w.amount > 0n) {
const weth = WETH.get(chainId);
heldByRouter.set(
weth,
w.amount === CONTRACT_BALANCE ||
heldByRouter.has(weth)
? null
: w.amount,
);
}
} else {
payeeUnread = true;
}
} else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]);
if (v4) {
// A SETTLE of OPEN_DELTA (zero) pays what the step's
// swap actions spend of its currency, which the step
// states as its amount when that currency is its
// Token In.
for (const s of v4.settledFromRouter) {
let amount = s.amount;
if (amount === 0n) {
amount =
s.currency === v4.tokenIn
? v4.amountIn
: null;
}
spendFromRouter(s.currency, amount);
}
for (const t of v4.takes) {
if (!isHop(i, t.currency, t.recipient)) {
addPayee(t.recipient, t.currency);
@@ -1400,9 +1487,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const u = decodeUnwrapWeth(inputs[i]);
if (u) {
addPayee(u.recipient);
if (!isRouter(u.recipient)) {
WETH_ADDRESSES.forEach((w) =>
heldByRouter.delete(w),
// It pays out all the router's WETH, as ETH. Into the
// router, the router then holds as ETH the WETH a
// step paid it.
const heldWeth = WETH_ADDRESSES.some((w) =>
heldByRouter.has(w),
);
WETH_ADDRESSES.forEach((w) => heldByRouter.delete(w));
if (isRouter(u.recipient) && heldWeth) {
heldByRouter.set(
"0x0000000000000000000000000000000000000000",
null,
);
}
} else {
@@ -1568,11 +1663,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// A Paid to line names each other address paid, and says when a
// step pays the bridge, when one moves the sender's tokens into the
// router, when the output stays in the router, and when a payout
// could not be read. The output is received by the sender only when
// some step pays the sender and there is no such line. When no step
// names anyone paid, one line says so. `paidTo` holds these lines and
// the token line after each move into the router.
// router, when a token stays in the router, and when a payout could
// not be read. The output is received by the sender only when some
// step pays the sender and there is no such line. When no step names
// anyone paid, one line says so. `paidTo` holds these lines and the
// token line after each line about a token in the router.
const outputHeld =
present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
const paidTo = paidElsewhere.map((address) => ({
@@ -1583,9 +1678,24 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (bridgeDeposit) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
}
// Each move into the router is followed by a line giving the moved
// token's address, in the shape of the Token In line, so the screens
// show it as they show that one.
// The line giving a token in the router, in the shape of the Token In
// line, so the screens show it as they show that one. ETH has no
// address.
const tokenMovedLine = (token) => {
const info = tokenInfo(token, sources);
if (!present(info.address)) {
return {
label: "Token moved into the router",
value: info.symbol,
};
}
return {
label: "Token moved into the router",
value: info.symbol ? info.symbol + " (" + token + ")" : token,
address: token,
isToken: true,
};
};
for (const m of movedIntoRouter) {
const info = tokenInfo(m.token, sources);
paidTo.push({
@@ -1595,18 +1705,22 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
amountText(m.amount, info).display +
" from the sender (later steps may not spend all of it)",
});
paidTo.push({
label: "Token moved into the router",
value: info.symbol
? info.symbol + " (" + m.token + ")"
: m.token,
address: m.token,
isToken: true,
});
paidTo.push(tokenMovedLine(m.token));
}
if (outputHeld) {
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
}
// Any other token the router still holds after the last step, such
// as one a swap step bought on the way to the output, what a V4
// SETTLE took beyond what the step's swap spent, or the WETH of a
// WRAP_ETH, stays there too, and its line is followed by the token's.
for (const token of heldByRouter.keys()) {
if (present(outputToken) && token === outputToken.toLowerCase()) {
continue;
}
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
paidTo.push(tokenMovedLine(getAddress(token)));
}
if (payeeUnread) {
paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY });
}
+4 -2
View File
@@ -739,7 +739,9 @@ describe("uniswap decoder", () => {
});
// Paid in ETH, with an exact-out step, the last swap step buys WETH, so
// UNWRAP_WETH makes the output ETH.
// UNWRAP_WETH makes the output ETH. That step is paid by the user, so the
// USDC the exact-out step bought stays in the router, and the minimum is
// not received (https://git.eeqj.de/sneak/AutistMask/issues/606).
test("an ETH-paid swap whose last step buys WETH, then UNWRAP_WETH, shows ETH", () => {
const data = buildExecute(
solidityPacked(
@@ -767,7 +769,7 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("0.4000 ETH");
expect(detail(result, "Min. paid out").value).toBe("0.4000 ETH");
});
// Sepolia's WETH is a different contract; UNWRAP_WETH makes it ETH too.
+330 -2
View File
@@ -4,9 +4,10 @@
// on the steps that pay it
// (https://git.eeqj.de/sneak/AutistMask/issues/602), the command each router
// release runs for a command type
// (https://git.eeqj.de/sneak/AutistMask/issues/592), and the address whose
// (https://git.eeqj.de/sneak/AutistMask/issues/592), the address whose
// balance a BALANCE_CHECK_ERC20 checks
// (https://git.eeqj.de/sneak/AutistMask/issues/600).
// (https://git.eeqj.de/sneak/AutistMask/issues/600), and any token a swap
// leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606).
//
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
@@ -546,6 +547,333 @@ describe("an output left in the router is not received", () => {
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/606: any token a step pays the
// router stays there unless a later step pays out or spends all of it, not
// only the swap's output. A V4 SETTLE can take more of the user's tokens than
// the step's swap spends, a WRAP_ETH wraps the user's ETH into the router, and
// a swap can leave there the token it bought on the way to the output.
describe("any token left in the router is not received", () => {
// The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
const USDT_5000 = 5000000000n;
// V4 SETTLE of `amount` of `currency` (zero: what the step's swap
// spends), paid by the user, or by the router when `payerIsUser` is false.
function settle(currency, amount, payerIsUser) {
return [
0x0b,
coder.encode(
["address", "uint256", "bool"],
[currency, amount, payerIsUser],
),
];
}
// V4 SWAP_EXACT_IN_SINGLE of `amountIn` of `currencyIn` (zero: the open
// delta) for at least `amountOutMin` of `currencyOut`.
function swapExactInSingle(
currencyIn,
currencyOut,
amountIn,
amountOutMin,
) {
return [
0x06,
coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[
[
[currencyIn, currencyOut, 500, 10, ZERO],
true,
amountIn,
amountOutMin,
"0x",
],
],
),
];
}
// The line giving a token left in the router.
const tokenLine = (token, symbol) => ({
label: "Token moved into the router",
value: symbol + " (" + token + ")",
address: token,
isToken: true,
});
test.each([
{
kind: "a V4 SETTLE of 5,000 USDT for a swap of 2,000, the rest taken to the router by TAKE",
data: execute(
v4Swap(
settle(USDT, USDT_5000, true),
SWAP_USDT_FOR_USDC,
take(USDT, ADDRESS_THIS),
takeAll(USDC),
),
),
left: tokenLine(USDT, "USDT"),
},
{
kind: "a V4 SETTLE of 5,000 USDT for a swap of 2,000, the rest taken to the router by TAKE_PORTION",
data: execute(
v4Swap(
settle(USDT, USDT_5000, true),
SWAP_USDT_FOR_USDC,
takeAll(USDC),
takePortion(USDT, ADDRESS_THIS, 10000n),
),
),
left: tokenLine(USDT, "USDT"),
},
{
kind: "a WRAP_ETH into the router that nothing spends",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a swap of 0.5 WETH from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v3SwapExactIn(
MSG_SENDER,
ONE_WETH / 2n,
USDC_1990,
WETH,
USDC,
false,
),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a swap paying WETH to the router, a TRANSFER of 1 wei of it to the WETH/USDC pair, then a V2 swap of what the pair holds",
data: execute(
swapToWeth(ADDRESS_THIS),
transfer(0x05, WETH, PAIR, 1n),
v2Swap(0x08, MSG_SENDER, 0n, USDC_1990, [WETH, USDC], false),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a swap paying WETH to the router, then a swap of 1 wei of it from the router",
data: execute(
swapToWeth(ADDRESS_THIS),
v2Swap(0x08, MSG_SENDER, 1n, USDC_1990, [WETH, USDC], false),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a swap paying WETH to the router, an UNWRAP_WETH into the router, then another swap",
data: execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC),
),
left: { label: "Token moved into the router", value: "ETH" },
},
])("$kind", ({ data, left }) => {
const result = decode(data);
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
"Token Out",
"Paid to",
"Token moved into the router",
"Min. paid out",
"Steps",
"Deadline",
]);
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Token moved into the router")).toEqual(left);
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
});
// Uniswap's own routes: a swap paid in ETH wraps into the router what its
// swap steps then spend from it, and a route through several pools pays
// each token it buys on the way to the router and spends all of it in the
// next step (CONTRACT_BALANCE), turning ETH into WETH or back where the
// pools need it.
test.each([
{
kind: "a V4 SETTLE of what its swap spends",
data: execute(
v4Swap(
settle(USDT, USDT_2000, true),
SWAP_USDT_FOR_USDC,
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
{
kind: "a V4 SETTLE of more than its swap spends, the rest taken to the user",
data: execute(
v4Swap(
settle(USDT, USDT_5000, true),
SWAP_USDT_FOR_USDC,
take(USDT, MSG_SENDER),
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
{
kind: "a WRAP_ETH into the router, then a swap of all of it from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v3SwapExactIn(
MSG_SENDER,
ONE_WETH,
USDC_1990,
WETH,
USDC,
false,
),
),
min: "1990.0000 USDC",
},
{
kind: "a WRAP_ETH into the router, then two swaps from the router that together spend all of it",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v3SwapExactIn(
MSG_SENDER,
(ONE_WETH * 3n) / 5n,
1n,
WETH,
USDC,
false,
),
v2Swap(
0x08,
MSG_SENDER,
(ONE_WETH * 2n) / 5n,
USDC_1990,
[WETH, USDC],
false,
),
),
min: "1990.0000 USDC",
},
{
kind: "a WRAP_ETH into the router, then a V4 swap of all of it settled from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990),
settle(WETH, 0n, false),
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
{
kind: "a WRAP_ETH of all the router's ETH, then a swap of all its WETH",
data: execute(
wrapEth(ADDRESS_THIS, CONTRACT_BALANCE),
v3SwapExactIn(
MSG_SENDER,
CONTRACT_BALANCE,
USDC_1990,
WETH,
USDC,
false,
),
),
min: "1990.0000 USDC",
},
{
kind: "a swap paying WETH to the router, then a V2 swap of all of it",
data: execute(
swapToWeth(ADDRESS_THIS),
v2Swap(
0x08,
MSG_SENDER,
CONTRACT_BALANCE,
USDC_1990,
[WETH, USDC],
false,
),
),
min: "1990.0000 USDC",
},
{
kind: "a swap paying WETH to the router, then a V4 swap settling all of it from the router",
data: execute(
swapToWeth(ADDRESS_THIS),
v4Swap(
settle(WETH, CONTRACT_BALANCE, false),
swapExactInSingle(WETH, USDC, 0n, USDC_1990),
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
{
kind: "a V4 swap taking USDC to the router, then a V3 swap of all of it",
data: execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, ADDRESS_THIS),
),
v3SwapExactIn(
MSG_SENDER,
CONTRACT_BALANCE,
ONE_WETH,
USDC,
WETH,
false,
),
),
min: "1.0000 WETH",
},
{
kind: "a V4 swap taking ETH to the router, a WRAP_ETH of all of it, then a V3 swap of all the WETH",
data: execute(
v4Swap(
SWAP_USDT_FOR_ETH,
SETTLE_USDT,
take(ZERO, ADDRESS_THIS),
),
wrapEth(ADDRESS_THIS, CONTRACT_BALANCE),
v3SwapExactIn(
MSG_SENDER,
CONTRACT_BALANCE,
USDC_1990,
WETH,
USDC,
false,
),
),
min: "1990.0000 USDC",
},
{
kind: "a swap paying WETH to the router, an UNWRAP_WETH into the router, then a V4 swap settling all the ETH from the router",
data: execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
v4Swap(
settle(ZERO, CONTRACT_BALANCE, false),
swapExactInSingle(ZERO, USDC, 0n, USDC_1990),
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
])("$kind reads as a swap paid to the user", ({ data, min }) => {
const result = decode(data);
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe(min);
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/599: TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION and V4 TAKE_PORTION pay a token to an address,
// and PERMIT2_TRANSFER_FROM moves the user's own tokens to one.