fix: a step the router lets fail is marked, and a balance check it lets fail sets no minimum (closes #602)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

Each Universal Router release now carries the command types its dispatch()
can return a failure for instead of reverting, read from its source; only a
step of one of those with the allow-revert flag (0x80) set can fail while
the transaction goes on. Such a step is listed with "(allowed to fail)". A
BALANCE_CHECK_ERC20 the router lets fail guarantees nothing, so it is read
as a zero check and keeps an earlier step's minimum. Every swap and every
payment of ETH or an ERC-20 token succeeds or reverts the whole transaction,
so the flag on them has no effect and they read as without it.

Model: opus-5-5
This commit is contained in:
2026-10-19 12:26:03 +00:00
parent 99269675b7
commit 14794a3931
4 changed files with 400 additions and 51 deletions
+15 -7
View File
@@ -987,13 +987,23 @@ describe("uniswap decoder", () => {
);
// A sub-plan holding a V3 swap of USDT for WETH, which the decoder does
// not read. With the allow-revert flag (0x80) the command byte is 0xa1.
// not read. With the allow-revert flag (0x80) the command byte is 0xa1,
// and the router lets the step fail
// (https://git.eeqj.de/sneak/AutistMask/issues/602).
test.each([
{ flag: "without", command: 0x21 },
{ flag: "with", command: 0xa1 },
{
flag: "without",
command: 0x21,
steps: "Execute Sub-Plan (its steps are not shown)",
},
{
flag: "with",
command: 0xa1,
steps: "Execute Sub-Plan (its steps are not shown) (allowed to fail)",
},
])(
"an EXECUTE_SUB_PLAN step $flag the allow-revert flag says its steps are not shown",
({ command }) => {
({ command, steps }) => {
const subPlan = encodeSubPlan("0x00", [
encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [
USDT_ADDR,
@@ -1013,9 +1023,7 @@ describe("uniswap decoder", () => {
USER_ADDR,
);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Steps").value).toBe(
"Execute Sub-Plan (its steps are not shown)",
);
expect(detail(result, "Steps").value).toBe(steps);
// The sub-plan names both currencies, so the lines must not say
// the calldata did not.
expect(detail(result, "Token In").value).toBe(
+263 -1
View File
@@ -1,6 +1,8 @@
// The address a swap's output is paid to
// (https://git.eeqj.de/sneak/AutistMask/issues/594 and
// https://git.eeqj.de/sneak/AutistMask/issues/599).
// https://git.eeqj.de/sneak/AutistMask/issues/599), and the allow-revert flag
// on the steps that pay it
// (https://git.eeqj.de/sneak/AutistMask/issues/602).
//
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
@@ -149,6 +151,20 @@ function transfer(command, token, recipient, amount) {
];
}
// PERMIT2_PERMIT (0x0a) of `amount` of `token`, for the router to spend.
function permit2Permit(token, amount) {
return [
0x0a,
coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
"bytes",
],
[[[token, amount, 0, 0], ROUTER, 0n], "0x"],
),
];
}
function permit2TransferFrom(token, recipient, amount) {
return [
0x02,
@@ -1362,6 +1378,252 @@ describe("paths whose recipients were untested", () => {
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/602: the top bit of a command
// byte (0x80) is the allow-revert flag. The router lets a step with it fail
// and goes on to the next step only for some commands, none of them a swap or
// a payment; on every other command the flag has no effect.
describe("the allow-revert flag", () => {
// UniversalRouterV1_2_V2Support on Ethereum Mainnet.
const ROUTER_V1_2 = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD";
// The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
// `step` with the allow-revert flag set on its command byte.
const flagged = ([command, input]) => [command | 0x80, input];
// `steps` with the flag set on the one at `index`.
const flagStep = (steps, index) =>
steps.map((s, i) => (i === index ? flagged(s) : s));
const usdcToRouter = v3SwapExactIn(
ADDRESS_THIS,
USDT_2000,
USDC_1990,
USDT,
USDC,
);
const wethToPair = v3SwapExactIn(PAIR, USDT_2000, 0n, USDT, WETH);
const pairSpends = v2Swap(
0x08,
MSG_SENDER,
0n,
USDC_1990,
[WETH, USDC],
false,
);
// The flag is set on the step at `index`: a swap, a payment, or the V2
// swap that a payment to its pair hops into.
test.each([
{
kind: "V2_SWAP_EXACT_IN",
steps: [
v2Swap(0x08, MSG_SENDER, USDT_2000, ONE_WETH, [USDT, WETH]),
],
index: 0,
},
{
kind: "V2_SWAP_EXACT_OUT",
steps: [
v2Swap(0x09, MSG_SENDER, ONE_WETH, USDT_2000, [USDT, WETH]),
],
index: 0,
},
{
kind: "V3_SWAP_EXACT_IN",
steps: [swapToWeth(MSG_SENDER)],
index: 0,
},
{
kind: "V4_SWAP",
steps: [v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))],
index: 0,
},
{
kind: "SWEEP",
steps: [usdcToRouter, sweep(USDC, MSG_SENDER)],
index: 1,
},
{
kind: "TRANSFER",
steps: [
usdcToRouter,
transfer(0x05, USDC, MSG_SENDER, CONTRACT_BALANCE),
],
index: 1,
},
{
kind: "PAY_PORTION",
steps: [usdcToRouter, transfer(0x06, USDC, MSG_SENDER, 10000n)],
index: 1,
},
{
kind: "PAY_PORTION_FULL_PRECISION",
steps: [usdcToRouter, transfer(0x07, USDC, MSG_SENDER, 10n ** 18n)],
index: 1,
},
{
kind: "UNWRAP_WETH",
steps: [swapToWeth(ADDRESS_THIS), unwrapWeth(MSG_SENDER)],
index: 1,
},
{
kind: "WRAP_ETH",
steps: [
wrapEth(ADDRESS_THIS, ONE_WETH),
v3SwapExactIn(
MSG_SENDER,
ONE_WETH,
USDC_1990,
WETH,
USDC,
false,
),
],
index: 0,
},
{
kind: "ACROSS_V4_DEPOSIT_V3",
steps: [
usdcToRouter,
acrossDeposit(USER, USER, USDC, CONTRACT_BALANCE),
],
index: 1,
},
{
kind: "PERMIT2_TRANSFER_FROM to the pair the next V2 swap spends",
steps: [permit2TransferFrom(WETH, PAIR, ONE_WETH), pairSpends],
index: 0,
},
{
kind: "a swap paid to the pair the next V2 swap spends",
steps: [wethToPair, pairSpends],
index: 0,
},
{
kind: "the V2 swap after a swap paid to its pair",
steps: [wethToPair, pairSpends],
index: 1,
},
])(
"$kind reads the same with the flag as without it",
({ steps, index }) => {
const plain = decode(execute(...steps), USER, ROUTER_V2_1_2);
const withFlag = decode(
execute(...flagStep(steps, index)),
USER,
ROUTER_V2_1_2,
);
expect(withFlag).toEqual(plain);
expect(detail(withFlag, "Steps").value).not.toContain(
"allowed to fail",
);
},
);
test.each([
{ router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 },
{ router: "UniversalRouterV2", address: ROUTER },
{ router: "UniversalRouterV2_1_2", address: ROUTER_V2_1_2 },
])(
"on $router, a BALANCE_CHECK_ERC20 with the flag is allowed to fail and does not replace the swap's minimum",
({ address }) => {
const check = balanceCheck(WETH, 2n * ONE_WETH);
const plain = decode(
execute(swapToWeth(MSG_SENDER), check),
USER,
address,
);
expect(detail(plain, "Steps").value).toBe(
"V3 Swap (Exact In) → Balance Check",
);
expect(detail(plain, "Min. received").value).toBe("2.0000 WETH");
const withFlag = decode(
execute(swapToWeth(MSG_SENDER), flagged(check)),
USER,
address,
);
expect(detail(withFlag, "Steps").value).toBe(
"V3 Swap (Exact In) → Balance Check (allowed to fail)",
);
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
},
);
test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, states none", () => {
const check = balanceCheck(WETH, ONE_WETH);
expect(detail(decode(execute(check)), "Min. paid out").value).toBe(
"1.0000 WETH",
);
const withFlag = decode(execute(flagged(check)));
expect(detail(withFlag, "Token Out").address).toBe(WETH);
expect(detail(withFlag, "Min. paid out").value).toBe(
"None (no minimum guaranteed)",
);
});
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
test.each([
{ router: "UniversalRouterV1", address: ROUTER_V1, mark: "" },
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
mark: "",
},
{
router: "UniversalRouterV2",
address: ROUTER,
mark: " (allowed to fail)",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2,
mark: " (allowed to fail)",
},
])(
"on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap reads as without the flag",
({ address, mark }) => {
const steps = [
permit2Permit(USDT, USDT_2000),
swapToWeth(MSG_SENDER),
];
const plain = decode(execute(...steps), USER, address);
const withFlag = decode(
execute(...flagStep(steps, 0)),
USER,
address,
);
expect(detail(withFlag, "Steps").value).toBe(
"Permit2 Permit" + mark + " → V3 Swap (Exact In)",
);
const withoutSteps = (r) =>
r.details.filter((d) => d.label !== "Steps");
expect(withoutSteps(withFlag)).toEqual(withoutSteps(plain));
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
},
);
test("a step the decoder does not read is marked too", () => {
const positionManagerCall = [0x12, "0x"];
const plain = decode(
execute(swapToWeth(MSG_SENDER), positionManagerCall),
);
expect(detail(plain, "Steps").value).toBe(
"V3 Swap (Exact In) → V3 Position Mgr Call",
);
const withFlag = decode(
execute(swapToWeth(MSG_SENDER), flagged(positionManagerCall)),
);
expect(detail(withFlag, "Steps").value).toBe(
"V3 Swap (Exact In) → V3 Position Mgr Call (allowed to fail)",
);
expect(paidTo(withFlag)).toEqual([UNDECODED]);
});
});
// The DOM stub both screens are drawn into.
function makeElement(id) {
const classes = new Set();