1 Commits
Author SHA1 Message Date
clawbot d7739547b3 fix: the approval screen names the address a swap's output is paid to (closes #594)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The swap decoder read no recipient, so a swap paying its output to another
address still showed Min. received. It now reads the address that every swap
step, SWEEP, WRAP_ETH, UNWRAP_WETH, V4 TAKE and TAKE_ALL pays. The minimum
reads Min. received only when some step pays the sender, none pays another
address, none that may pay one went unread, and the output is not left in the
router: an output paid to the router needs a later SWEEP of that token, or
UNWRAP_WETH of WETH, paying it out. A payment to the V2 pair that the next V2
swap spends as already paid is a hop, not a payout; the pair is derived as the
router derives it. Otherwise it reads Min. paid out, with Paid to lines.

Model: opus-5-5
2026-10-19 08:44:45 +00:00
4 changed files with 214 additions and 57 deletions
+14 -11
View File
@@ -2934,17 +2934,20 @@ Currently supported:
address, is not counted as paid out. When a swap step or `TAKE` pays the
router the swap's output token, the output stays in the router unless a later
`SWEEP` of that token, or a later `UNWRAP_WETH` when it is WETH, pays it out.
A swap step paying an address that the very next step spends, as a V2 exact-in
of the same 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. Every
other payment counts, in whichever step and of whatever token. 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), no step pays any other address, no step that may pay an
address went unread, and the output does not stay in the router. Otherwise it
reads `Min. paid out`, a `Paid to` line after `Token Out` names each other
address paid, one reads
A swap step or `TAKE` 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 as
the router does 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. Every other payment counts, in
whichever step and of whatever token. 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), no step pays any other
address, no step that may pay an address went unread, and the output does not
stay in the router. Otherwise it reads `Min. paid out`, a `Paid to` line after
`Token Out` names each other address paid, 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 such a
step went unread, or `Unknown (not named in the calldata)` when no step pays
+72 -13
View File
@@ -1,7 +1,15 @@
// Decode Uniswap Universal Router execute() calldata into human-readable
// swap details. Designed to be extended with other DEX decoders later.
const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers");
const {
Interface,
AbiCoder,
getBytes,
formatUnits,
getCreate2Address,
keccak256,
solidityPacked,
} = require("ethers");
const { truncateAmountNeverZero } = require("./amountDisplay");
const {
resolveTokenDecimals,
@@ -65,6 +73,53 @@ function routerCommandTypeMask(address, chainId) {
return router ? router[1] : null;
}
// The Uniswap V2 factory and pair init code hash the Universal Routers on each
// network were deployed with. A V2 swap step swaps through the pair its path
// starts at, which the router finds as `UniswapV2Library.pairFor()` does: the
// CREATE2 address from the factory, with the hash of the path's first two
// tokens, sorted, as salt.
// - pairFor(): https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/modules/uniswap/v2/UniswapV2Library.sol
// - mainnet: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/script/deployParameters/DeployMainnet.s.sol
// - Sepolia: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/script/deployParameters/DeploySepolia.s.sol
// Every router in ROUTER_ADDRESSES was deployed with these (their constructor
// arguments on the explorer), except Sepolia's UniversalRouterV1_2_V2Support,
// whose init code hash is zero. On Sepolia neither hash is that of the
// factory's own pairs, so no contract sits at a pair any router there finds,
// and every V2 swap step they run reverts.
const V2_FACTORIES = new Map([
[
NETWORKS.mainnet.chainId,
{
v2Factory: "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f",
pairInitCodeHash:
"0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f",
},
],
[
NETWORKS.sepolia.chainId,
{
v2Factory: "0xB7f907f7A9eBC822a80BD25E224be42Ce0A698A0",
pairInitCodeHash:
"0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f",
},
],
]);
// The address of the V2 pair of `tokenA` and `tokenB` on the network with this
// chain id, found as the router finds it (see V2_FACTORIES).
function v2PairFor(chainId, tokenA, tokenB) {
const { v2Factory, pairInitCodeHash } = V2_FACTORIES.get(chainId);
const [token0, token1] =
tokenA.toLowerCase() < tokenB.toLowerCase()
? [tokenA, tokenB]
: [tokenB, tokenA];
return getCreate2Address(
v2Factory,
keccak256(solidityPacked(["address", "address"], [token0, token1])),
pairInitCodeHash,
);
}
// Universal Router command types, each read from its command byte with the
// router's mask (see ROUTER_ADDRESSES)
const COMMAND_NAMES = {
@@ -295,6 +350,7 @@ function decodeV2SwapExactIn(input) {
recipient: d[0],
amountIn: d[1] === 0n ? ALREADY_PAID : d[1],
amountOutMin: d[2],
path: d[3],
tokenIn: d[3][0],
tokenOut: d[3][d[3].length - 1],
};
@@ -786,14 +842,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidElsewhere.push(recipient);
}
};
// A swap step that pays `token` to an address the very next step
// spends, as a V2 exact-in of that token whose input is already paid,
// is a hop, not a payout: Uniswap's mixed routes pay the next V2 pair
// directly, and the router enforces that next swap's own minimum. Its
// input is already paid when its amountIn is ALREADY_PAID, or
// A swap step or V4 TAKE that pays `token` to `recipient` is a hop,
// not a payout, when the very next step is a V2 exact-in whose input
// is already paid, whose path starts at `token`, and whose pair (see
// v2PairFor()) is `recipient`: Uniswap's mixed routes pay the next V2
// pair directly, and the router enforces that next swap's own minimum.
// Its input is already paid when its amountIn is ALREADY_PAID, or
// CONTRACT_BALANCE, which Uniswap's routes send and with which the
// router adds only what it holds.
const isHop = (i, token) => {
const isHop = (i, token, recipient) => {
if (i + 1 >= commandsBytes.length) return false;
if ((commandsBytes[i + 1] & commandTypeMask) !== 0x08) return false;
const next = decodeV2SwapExactIn(inputs[i + 1]);
@@ -802,8 +859,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
(next.amountIn === ALREADY_PAID ||
next.amountIn === CONTRACT_BALANCE) &&
present(token) &&
present(next.tokenIn) &&
next.tokenIn.toLowerCase() === token.toLowerCase()
next.path.length >= 2 &&
next.tokenIn.toLowerCase() === token.toLowerCase() &&
recipient.toLowerCase() ===
v2PairFor(chainId, next.path[0], next.path[1]).toLowerCase()
);
};
@@ -847,7 +906,7 @@ 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 (!isHop(i, s.tokenOut)) {
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
}
} else {
@@ -858,7 +917,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
setInputOnce(s.tokenIn, s.amountIn);
setOutput(s.tokenOut, s.amountOutMin);
if (!isHop(i, s.tokenOut)) {
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
}
} else {
@@ -871,7 +930,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
setInputOnce(s.tokenIn, s.amountInMax);
setOutput(s.tokenOut, s.amountOut);
if (!isHop(i, s.tokenOut)) {
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
}
} else {
@@ -902,7 +961,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const v4 = decodeV4Swap(inputs[i]);
if (v4) {
for (const t of v4.takes) {
if (!isHop(i, t.currency)) {
if (!isHop(i, t.currency, t.recipient)) {
addPayee(t.recipient, t.currency);
}
}
+15 -17
View File
@@ -14,27 +14,25 @@ const DAI_ADDR = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
const SEPOLIA_WETH_ADDR = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// AutistMask's first-ever swap, 2026-02-27.
// AutistMask's first-ever swap, 2026-02-27, sent from USER_ADDR.
// Swapped USDT for ETH via Uniswap V4 Universal Router.
// https://etherscan.io/tx/0x6749f50c4e8f975b6d14780d5f539cf151d1594796ac49b7d6a5348ba0735e77
// This is that transaction's input, as a public Ethereum node returns it.
const FIRST_SWAP_CALLDATA =
"0x3593564c" +
"000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0" +
"0000000000000000000000000000000000000000000000000000000069a1550f00000000000000000000000000000000000000000000000000000000000000020a10000000000000000000000000000000000000000000000000000000000000" +
"0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c0" +
"0000000000000000000000000000000000000000000000000000000000000160000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000ffffffffffffffffffffffffffffffffffffffff" +
"0000000000000000000000000000000000000000000000000000000069c8daf6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af" +
"0000000000000000000000000000000000000000000000000000000069a154fe00000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000041" +
"230249bb7133205db7b2389b587c723cc182302907b9545dc40c59c33ad1d53078a65732f4182fedbc0d9d85c51d580bdc93db3556fac38f18e140da47d0eb631c00000000000000000000000000000000000000000000000000000000000000" +
"000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000069a1550f0000000000000000000000000000000000000000000000000000000000000002" +
"0a100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c0" +
"0000000000000000000000000000000000000000000000000000000000000160000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000ffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000069c8daf6" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af0000000000000000000000000000000000000000000000000000000069a154fe00000000000000000000000000000000000000000000000000000000000000e0" +
"0000000000000000000000000000000000000000000000000000000000000041230249bb7133205db7b2389b587c723cc182302907b9545dc40c59c33ad1d53078a65732f4182fedbc0d9d85c51d580bdc93db3556fac38f18e140da47d0eb631c00000000000000000000000000000000000000000000000000000000000000" +
"00000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003" +
"070b0e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220" +
"00000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7" +
"0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000007a1200000000000000000000000000000000000000000000000000000dcb050d338e7" +
"0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064" +
"0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0" +
"0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"dac17f958d2ee523a2206206994597c13d831ec70000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"66133e8ea0f5d1d612d2502a968757d1048c214a0000000000000000000000000000000000000000000000000000000000000000756e6978000000000012";
"0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000007a1200000000000000000000000000000000000000000000000000000dcb050d338e70000000000000000000000000000000000000000000000000000000000000001" +
"0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000001" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060" +
"000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000060" +
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066133e8ea0f5d1d612d2502a968757d1048c214a0000000000000000000000000000000000000000000000000000000000000000756e6978000000000012";
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
@@ -362,11 +360,11 @@ describe("uniswap decoder", () => {
expect(result.details.find((d) => d.label === "Amount").value).toBe(
"Unlimited",
);
// This fixture's TAKE parameters are one byte long, so who it pays
// cannot be read, and the minimum is not said to be received.
// Its TAKE pays the From address, so the minimum is received.
expect(
result.details.find((d) => d.label === "Min. paid out").value,
result.details.find((d) => d.label === "Min. received").value,
).toBe("0.0002 ETH");
expect(labels).not.toContain("Paid to");
// Real mainnet calldata is in the deployed shape, so its V4 step is
// not said to be undecoded.
+113 -16
View File
@@ -424,18 +424,24 @@ describe("an output left in the router is not received", () => {
// Uniswap's mixed routes pay a V2 pair directly when the next step swaps
// through it. That V2 swap spends what the pair holds: its amountIn is
// CONTRACT_BALANCE, paid from the router, or ALREADY_PAID.
// CONTRACT_BALANCE, paid from the router, or ALREADY_PAID. The pairs below
// are the addresses the routers find for the first two tokens of that V2
// swap's path.
describe("a swap paid to the pair the next V2 swap spends", () => {
const PAIR = "0x5555555555555555555555555555555555555555";
// The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
const CONTRACT_BALANCE = 2n ** 255n;
const swapToPair = v3SwapExactIn(PAIR, USDT_2000, 0n, USDT, WETH);
const AMOUNTS_IN = [
{ kind: "CONTRACT_BALANCE", amountIn: CONTRACT_BALANCE },
{ kind: "ALREADY_PAID", amountIn: 0n },
];
const swapTo = (recipient) =>
v3SwapExactIn(recipient, USDT_2000, 0n, USDT, WETH);
const swapToPair = swapTo(PAIR);
const pairSpends = (amountIn, path) =>
v2Swap(0x08, MSG_SENDER, amountIn, USDC_1990, path, false);
test.each([
{ kind: "CONTRACT_BALANCE", amountIn: CONTRACT_BALANCE },
{ kind: "ALREADY_PAID", amountIn: 0n },
])(
test.each(AMOUNTS_IN)(
"a V3 swap, then a V2 swap whose amountIn is $kind, reads as a swap paid to the user",
({ amountIn }) => {
const result = decode(
@@ -450,22 +456,113 @@ describe("a swap paid to the pair the next V2 swap spends", () => {
},
);
test("a V4 step whose TAKE pays the pair, then the V2 swap, reads the same", () => {
const result = decode(
test.each(AMOUNTS_IN)(
"a V3 swap paid to another address, then a V2 swap whose amountIn is $kind, names that address",
({ amountIn }) => {
const result = decode(
execute(swapTo(OTHER), pairSpends(amountIn, [WETH, USDC])),
);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe(
"1990.0000 USDC",
);
},
);
test.each([
{ recipient: PAIR, lines: PAID_TO_USER, paid: [] },
{ recipient: OTHER, lines: PAID_ELSEWHERE, paid: [OTHER] },
])(
"a V4 step whose TAKE pays $recipient, then the V2 swap",
({ recipient, lines, paid }) => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, recipient),
),
v2Swap(
0x08,
MSG_SENDER,
CONTRACT_BALANCE,
ONE_WETH,
[USDC, WETH],
false,
),
),
);
expect(labels(result)).toEqual(lines);
expect(paidTo(result)).toEqual(paid);
},
);
describe("on Sepolia", () => {
const SEPOLIA = "0xaa36a7";
// UniversalRouterV2_1_2 on Sepolia.
const SEPOLIA_ROUTER = "0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3";
// The WETH/USDC pair the routers on Sepolia find. Sepolia's V2
// factory reports another address for it, and no contract sits at
// this one (see V2_FACTORIES in src/shared/uniswap.js).
const SEPOLIA_PAIR = "0x541b50d32B73E769ac42F0FddD86D15FcAa861Fb";
const SEPOLIA_UNI = "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984";
const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
const SEPOLIA_USDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238";
// UNI for WETH, paid to `recipient`, then WETH for USDC through the
// pair.
const route = (recipient, amountIn) =>
execute(
v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, PAIR)),
v3SwapExactIn(
recipient,
ONE_WETH,
0n,
SEPOLIA_UNI,
SEPOLIA_WETH,
),
v2Swap(
0x08,
MSG_SENDER,
CONTRACT_BALANCE,
ONE_WETH,
[USDC, WETH],
amountIn,
USDC_1990,
[SEPOLIA_WETH, SEPOLIA_USDC],
false,
),
),
);
test.each(AMOUNTS_IN)(
"paid to the pair, then a V2 swap whose amountIn is $kind, reads as a swap paid to the user",
({ amountIn }) => {
const result = uniswap.decode(
route(SEPOLIA_PAIR, amountIn),
SEPOLIA_ROUTER,
SEPOLIA,
{},
USER,
);
expect(labels(result)).toEqual(PAID_TO_USER);
},
);
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe("1.0000 WETH");
test.each(AMOUNTS_IN)(
"paid to another address, then a V2 swap whose amountIn is $kind, names that address",
({ amountIn }) => {
const result = uniswap.decode(
route(OTHER, amountIn),
SEPOLIA_ROUTER,
SEPOLIA,
{},
USER,
);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([OTHER]);
},
);
test("the same route on Ethereum Mainnet names the Sepolia pair", () => {
const result = decode(route(SEPOLIA_PAIR, 0n));
expect(paidTo(result)).toEqual([SEPOLIA_PAIR]);
});
});
test.each([