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
This commit is contained in:
2026-10-19 16:32:20 +00:00
parent d6fb002656
commit 1af7c32b02
4 changed files with 534 additions and 78 deletions
+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 });
}