fix: WETH paid to the user before an UNWRAP_WETH reads as WETH (closes #607)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s

An UNWRAP_WETH made Token Out ETH whenever the output side was WETH, so
a swap that paid its WETH to the user before it read as ETH, though the
user receives WETH. decode() now turns the output into ETH only when the
router holds the output side's WETH when the step runs, which it reads
from what it already follows the router to hold; a later step that sets
the output side sets it anew. So an UNWRAP_WETH that runs before the
swap no longer makes the WETH the swap leaves in the router read as ETH.
README Display Consistency says the same.

Model: opus-5-5
This commit is contained in:
2026-10-20 10:52:09 +00:00
parent 070ad82338
commit e3745a2acf
4 changed files with 98 additions and 16 deletions
+8 -2
View File
@@ -1251,8 +1251,14 @@ A V4 step with no swap action sets only the sides its `SETTLE`, `SETTLE_ALL`,
`TAKE` or `TAKE_ALL` names. `TAKE` or `TAKE_ALL` names.
An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side is WETH, 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 on mainnet or Sepolia, that the router holds when the step runs (Transaction
`Min. received` figure then reads in ETH. Otherwise `Token Out` and Decoding below gives what the router holds), or when no step set the output
side; a WETH `Min. received` figure then reads in ETH. A later step that sets
the output side sets it anew. The router unwraps only the WETH it holds, so a
swap that pays its WETH to the user and then unwraps shows WETH. The decoder
follows what the router holds by token, not by step: a swap that pays part of
its WETH to the user and part to the router, and then unwraps, shows ETH with
the minimum of the step that set the output side. Otherwise `Token Out` and
`Min. received` keep the output side's own token and figure, whether the swap `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 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 unwraps the WETH it did not spend shows USDC. The token permission warning on
+25 -9
View File
@@ -1219,6 +1219,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let minOutput = null; let minOutput = null;
let outputEstablished = false; let outputEstablished = false;
let hasUnwrapWeth = false; let hasUnwrapWeth = false;
// Set when an UNWRAP_WETH pays out, as ETH, the output side's WETH,
// which the router holds; cleared whenever the output side is set
// again.
let outputUnwrapped = false;
let hasV2OrV3ExactOut = false; let hasV2OrV3ExactOut = false;
let swapUndecoded = false; let swapUndecoded = false;
let hasSubPlan = false; let hasSubPlan = false;
@@ -1260,6 +1264,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputToken = present(token) ? token : null; outputToken = present(token) ? token : null;
minOutput = present(amount) ? amount : null; minOutput = present(amount) ? amount : null;
outputEstablished = true; outputEstablished = true;
outputUnwrapped = false;
}; };
// What the steps take from the sender beyond the amount of the step // What the steps take from the sender beyond the amount of the step
@@ -1744,6 +1749,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputToken = v4.tokenOut; outputToken = v4.tokenOut;
minOutput = v4.amountOutMin; minOutput = v4.amountOutMin;
outputEstablished = true; outputEstablished = true;
outputUnwrapped = false;
} else if (v4) { } else if (v4) {
// A step with no swap action sets only the sides its // A step with no swap action sets only the sides its
// SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names: // SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names:
@@ -1772,6 +1778,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
} else if (command === "UNWRAP_WETH") { } else if (command === "UNWRAP_WETH") {
hasUnwrapWeth = true; hasUnwrapWeth = true;
// The router unwraps all the WETH it holds, the output
// side's included when it is WETH the router holds, even
// when the recipient cannot be read. WETH a step paid to
// anyone else is not unwrapped.
if (
present(outputToken) &&
WETH_ADDRESSES.includes(outputToken.toLowerCase()) &&
heldByRouter.has(outputToken.toLowerCase())
) {
outputUnwrapped = true;
}
const u = decodeUnwrapWeth(inputs[i]); const u = decodeUnwrapWeth(inputs[i]);
if (u) { if (u) {
addPayee(u.recipient); addPayee(u.recipient);
@@ -1912,23 +1929,22 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputToken = null; outputToken = null;
minOutput = null; minOutput = null;
outputEstablished = true; outputEstablished = true;
outputUnwrapped = false;
} }
// Resolve token info. A null token on either side means no step named // Resolve token info. A null token on either side means no step named
// a currency for it, a V4 step with a swap action left it unknown, or // a currency for it, a V4 step with a swap action left it unknown, or
// an EXECUTE_SUB_PLAN step did; tokenInfo() refuses rather than calling // an EXECUTE_SUB_PLAN step did; tokenInfo() refuses rather than calling
// it ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH // it ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH
// when the output side is WETH, or when no step set the output side. A // when it unwraps the output side's WETH (`outputUnwrapped`), or when
// V4 step with a swap action, or an EXECUTE_SUB_PLAN step, did set it, // no step set the output side. A V4 step with a swap action, or
// even when it set it unknown. Any other output keeps its own token // an EXECUTE_SUB_PLAN step, did set it, even when it set it unknown.
// and figure: a swap that buys USDC and then unwraps the WETH it did // Any other output keeps its own token and figure: a swap that pays
// not spend receives USDC. // its WETH to the user and then unwraps receives WETH, and one that
const outputIsWeth = // buys USDC and then unwraps the WETH it did not spend receives USDC.
present(outputToken) &&
WETH_ADDRESSES.includes(outputToken.toLowerCase());
const inInfo = tokenInfo(inputToken, sources); const inInfo = tokenInfo(inputToken, sources);
const outInfo = const outInfo =
hasUnwrapWeth && (outputIsWeth || !outputEstablished) outputUnwrapped || (hasUnwrapWeth && !outputEstablished)
? { symbol: "ETH", decimals: 18, address: null } ? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources); : tokenInfo(outputToken, sources);
+2 -2
View File
@@ -875,7 +875,7 @@ describe("uniswap decoder", () => {
const data = buildExecute( const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x08, 0x0c]), solidityPacked(["uint8", "uint8"], [0x08, 0x0c]),
[ [
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [ encodeV2SwapExactIn(ROUTER_ADDR, 1000000n, 500000000000000n, [
USDC_ADDR, USDC_ADDR,
SEPOLIA_WETH_ADDR, SEPOLIA_WETH_ADDR,
]), ]),
@@ -958,7 +958,7 @@ describe("uniswap decoder", () => {
const data = buildExecute( const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x08, 0x0c]), solidityPacked(["uint8", "uint8"], [0x08, 0x0c]),
[ [
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [ encodeV2SwapExactIn(ROUTER_ADDR, 1000000n, 500000000000000n, [
USDT_ADDR, USDT_ADDR,
WETH_ADDR, WETH_ADDR,
]), ]),
+63 -3
View File
@@ -7,9 +7,11 @@
// (https://git.eeqj.de/sneak/AutistMask/issues/592), the address whose // (https://git.eeqj.de/sneak/AutistMask/issues/592), the address whose
// balance a BALANCE_CHECK_ERC20 checks // balance a BALANCE_CHECK_ERC20 checks
// (https://git.eeqj.de/sneak/AutistMask/issues/600), any token a swap // (https://git.eeqj.de/sneak/AutistMask/issues/600), any token a swap
// leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606), and // leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606),
// what each PERMIT2_TRANSFER_FROM moves // what each PERMIT2_TRANSFER_FROM moves
// (https://git.eeqj.de/sneak/AutistMask/issues/611). // (https://git.eeqj.de/sneak/AutistMask/issues/611), and when an
// UNWRAP_WETH makes the output ETH
// (https://git.eeqj.de/sneak/AutistMask/issues/607).
// //
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION, // Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and // PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
@@ -531,7 +533,7 @@ describe("an output left in the router is not received", () => {
{ {
kind: "an UNWRAP_WETH to the user, then the swap", kind: "an UNWRAP_WETH to the user, then the swap",
data: execute(unwrapWeth(MSG_SENDER), toRouter), data: execute(unwrapWeth(MSG_SENDER), toRouter),
min: "1.0000 ETH", min: "1.0000 WETH",
}, },
{ {
kind: "the swap, then a WRAP_ETH of zero to the user", kind: "the swap, then a WRAP_ETH of zero to the user",
@@ -568,6 +570,64 @@ describe("an output left in the router is not received", () => {
}); });
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/607: an UNWRAP_WETH pays out,
// as ETH, the WETH the router holds when it runs. Token Out read ETH whenever
// the output side was WETH, so a swap that paid its WETH to the user before
// the UNWRAP_WETH read as ETH, though the user receives WETH.
describe("an UNWRAP_WETH makes the output ETH only when the router holds it", () => {
test("a swap paying its WETH to the user, then an UNWRAP_WETH, reads as WETH", () => {
const result = decode(
execute(swapToWeth(MSG_SENDER), unwrapWeth(MSG_SENDER)),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe("1.0000 WETH");
});
test("a swap paying its WETH to the router, then an UNWRAP_WETH to the user, reads as ETH", () => {
const result = decode(
execute(swapToWeth(ADDRESS_THIS), unwrapWeth(MSG_SENDER)),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
});
// The user receives the first step's WETH as WETH and the second's as
// ETH. The second step sets the output side, and the router holds its
// WETH, so the output reads as ETH with the second step's minimum.
test("a swap paying part of its WETH to the user and the rest to the router, then an UNWRAP_WETH, reads as ETH", () => {
const result = decode(
execute(
swapToWeth(MSG_SENDER),
v2Swap(0x08, ADDRESS_THIS, USDC_1990, ONE_WETH / 2n, [
USDC,
WETH,
]),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received").value).toBe("0.5000 ETH");
});
// A step after the UNWRAP_WETH that sets the output side sets it anew.
test("a swap paying WETH to the user after an UNWRAP_WETH of an earlier swap's WETH reads as WETH", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(MSG_SENDER),
v2Swap(0x08, MSG_SENDER, USDC_1990, ONE_WETH / 2n, [
USDC,
WETH,
]),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Min. received").value).toBe("0.5000 WETH");
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/606: any token a step pays the // 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 // 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 // only the swap's output. A V4 SETTLE can take more of the user's tokens than