Skip to content

fix: OZ 2.3.0 audit fixes (L-01, N-02, N-03, N-04, N-05, N-06, N-07) - #523

Merged
dianakocsis merged 12 commits into
dev-2.3.0from
fix/oz-2.3.0-audit-findings
Oct 1, 2026
Merged

dianakocsis merged 12 commits into
dev-2.3.0from
fix/oz-2.3.0-audit-findings

Conversation

@ccashwell

@ccashwell ccashwell commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

What

Fixes for OpenZeppelin's Universal Router v2.3.0 audit (report dated 2026-09-21), one commit per finding, on dev-2.3.0. The periphery-side findings (L-02, N-01, N-05 quoter, N-08) live in v4-periphery PR (link), which this PR pins.

Finding Change Commit
L-01 A sub-plan failing with NestedExecutionNotPermitted is re-thrown even under FLAG_ALLOW_REVERT, so the gate cannot be turned into a silent skip that strands the route's input 6e03c1f
N-02 V2 donation note corrected: a donation only reverts the route when it trips the per-hop bound, and skim(to) lets whoever calls it first claim the excess, not necessarily the donor 7c06b8a, dc12d68
N-03 Deadline-free executeNested overload, sharing one private body with the deadline form c746e0b
N-04 V2 exact-output routes now measure delivery at the recipient and revert on a shortfall; the check moved into the hop loop so exact input and exact output share it 4fa5b89
N-05 README: an exact-output swap paid from the router's own balance (payerIsUser = false or a native input) leaves the unspent pre-fund in the router, so such plans must end with a full-balance return; a hook that funds the input is the zero-cost extreme d6297b1, 13db8b0
N-06 BytesLib.toLengthOffset compares its head-word guard in whole words, so a huge index cannot wrap past it c6078b7
N-07 README lists the commands FLAG_ALLOW_REVERT affects, points at EXECUTE_SUB_PLAN for the rest, and notes the nested gate is never swallowed b84eca4
v4-periphery pinned to the fix branch at 02ffbe93 1044409

Bytecode

Runtime Free under 24,576
dev-2.3.0 before 23,980 596
This PR 24,228 348

Periphery L-02 and N-08 together cost 72 bytes on the router, L-01 149, N-06 8. N-03 and N-04 net 19 together, because moving the exact-input delivery check into the shared loop paid for most of the exact-output check.

Testing

  • New tests: allow-revert sub-plan cannot skip the gate, and still runs when opted in; deadline-free executeNested inside an unlock; v2 exact output with a fee-on-transfer output token, a fee-on-transfer intermediate token, and a withholding token, plus the standard-token control; toLengthOffset wrapping index and out-of-bounds fuzz.
  • forge test --isolate with FORK_URL: 186 passed, 0 failed.
  • yarn test:hardhat with FORK_URL: see the snapshot commit.

Before merging

Re-pin lib/v4-periphery to the squash commit of the periphery PR on dev-ur-2.3.0 once it lands, as #508 did.

AI-Generated Description

What

Fixes for OpenZeppelin's Universal Router v2.3.0 audit (report dated 2026-09-21), one commit per finding, on dev-2.3.0. The periphery-side findings (L-02, N-01, N-05 quoter, N-08) live in v4-periphery PR, which this PR pins.

Finding Change Commit
L-01 A sub-plan failing with NestedExecutionNotPermitted is re-thrown even under FLAG_ALLOW_REVERT, so the gate cannot be turned into a silent skip that strands the route's input 6e03c1f
N-02 V2 donation note corrected: a donation only reverts the route when it trips the per-hop bound, and skim(to) lets whoever calls it first claim the excess, not necessarily the donor 7c06b8a, dc12d68
N-03 Deadline-free executeNested overload, sharing one private body with the deadline form c746e0b
N-04 V2 exact-output routes now measure delivery at the recipient and revert on a shortfall; the check moved into the hop loop so exact input and exact output share it 4fa5b89
N-05 README: an exact-output swap paid from the router's own balance (payerIsUser = false or a native input) leaves the unspent pre-fund in the router, so such plans must end with a full-balance return; a hook that funds the input is the zero-cost extreme d6297b1, 13db8b0
N-06 BytesLib.toLengthOffset compares its head-word guard in whole words, so a huge index cannot wrap past it c6078b7
N-07 README lists the commands FLAG_ALLOW_REVERT affects, points at EXECUTE_SUB_PLAN for the rest, and notes the nested gate is never swallowed b84eca4
v4-periphery pinned to the fix branch at 02ffbe93 1044409

Changes

  • Dispatcher.sol: After an EXECUTE_SUB_PLAN call, check whether the revert is NestedExecutionNotPermitted and re-throw it regardless of FLAG_ALLOW_REVERT (L-01)
  • UniversalRouter.sol / IUniversalRouter.sol: Add a deadline-free executeNested(commands, inputs) overload; both overloads now delegate to a shared _executeNested private function (N-03)
  • V2SwapRouter.sol: Move the delivery check (balanceBefore / balanceOf comparison) into _v2Swap so both exact-input and exact-output paths share it; exact-output passes amountOut as the minimum (N-04)
  • BytesLib.sol: Replace the byte-offset overflow-prone guard gt(add(headOffset, 0x20), _bytes.length) with the whole-word comparison iszero(gt(div(_bytes.length, 0x20), _arg)) (N-06)
  • README.md: Correct the V2 donation note (N-02), document hook-funded exact-output input retention (N-05), list which commands FLAG_ALLOW_REVERT affects (N-07)
  • lib/v4-periphery: Bumped to 02ffbe93 (the OZ 2.3.0 fix branch)

Bytecode

Runtime Free under 24,576
dev-2.3.0 before 23,980 596
This PR 24,228 348
Periphery L-02 and N-08 together cost 72 bytes on the router, L-01 149, N-06 8. N-03 and N-04 net 19 together, because moving the exact-input delivery check into the shared loop paid for most of the exact-output check.

Testing

  • New tests: allow-revert sub-plan cannot skip the gate, and still runs when opted in; deadline-free executeNested inside an unlock; v2 exact output with a fee-on-transfer output token, a fee-on-transfer intermediate token, and a withholding token, plus the standard-token control; toLengthOffset wrapping index and out-of-bounds fuzz
  • forge test --isolate with FORK_URL: 186 passed, 0 failed
  • yarn test:hardhat with FORK_URL: see the snapshot commit

Before merging

Re-pin lib/v4-periphery to the squash commit of the periphery PR on dev-ur-2.3.0 once it lands, as #508 did.

…tion gate

A sub-plan flagged allow-revert that tripped NestedExecutionNotPermitted
was reported as an ordinary failure, so a plain execute inside a foreign
unlock could wrap its V4_SWAP in such a sub-plan, skip the swap silently,
and leave the route's input in the router. The gate is a consent check,
not a route failure: when a sub-plan fails with exactly that selector the
Dispatcher re-throws it. OZ 2.3.0 audit L-01.
The note said a donation drives the price below minHopPriceX36 and reverts,
and that skim() returns it. A donation only reverts the route when it trips
the bound, otherwise it is swapped along with the input, and skim(to) sends
excess to whoever calls it rather than back to the donor. Wording now
matches the V3 note. OZ 2.3.0 audit N-02.
…ut behind

Since #584 a route whose hook funds the swap input succeeds without
touching what the plan pre-funded the router with. Document that plans
funding the router must end with a full-balance return, since any balance
left in the router can be swept by anyone. OZ 2.3.0 audit N-05.
toLengthOffset checked the head word with add(32 * _arg, 32) against the
slice length. For _arg >= 2^251 the multiplication wraps, the check passes,
and the read lands on a different element. Every caller passes a small
constant today, so this is defensive, but the wrap-free comparison costs a
few bytes and removes the assumption. OZ 2.3.0 audit N-06.
execute has always had a deadline-free form for composers that enforce
their own deadline, but executeNested only shipped with the deadline
variant, so contracts opting into a shared unlock had to pass a dummy
deadline. Both variants now share one private body. OZ 2.3.0 audit N-03.
v2SwapExactOutput computed the input from reserves, paid the first pair and
ran the hops without ever checking what reached the recipient, so a
fee-on-transfer or withholding token at any hop let the route succeed on a
shortfall, uncapped for a token that keeps what it is sent. The recipient
balance check that exact input already performed now lives in the hop loop
and both entrypoints pass their bound: exact output passes the requested
amount itself. Sharing the two balance reads makes the fix cheaper than the
check it replaces. OZ 2.3.0 audit N-04.
The flag description read as if any command could be allowed to revert.
Only commands built on an external call report failure through it; swaps,
transfers, wraps, sweeps and the Across deposit are internal calls and
revert the transaction regardless, so the README now lists the commands
the flag covers, points at EXECUTE_SUB_PLAN for the rest, and notes that
the nested-execution gate is never swallowed. OZ 2.3.0 audit N-07.
Picks up EmptyPath on the multi-hop swaps (L-02), the corrected exact-output
comment (N-01), the head-word bound in CalldataDecoder.toBytes (N-08) and
the V4Quoter fixes from v4-periphery #588 (N-05), at 02ffbe93.
skim(to) pays whoever calls it first, which can be the donor, so "not back
to the donor" overstated it. The note also now covers a disabled per-hop
bound, where a donation is always swapped along with the input. OZ 2.3.0
audit N-02.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread README.md Outdated
…l case

Any exact-output swap paid from the router's own balance spends only what
the route costs, so the unspent pre-fund stays in the router whether or not
a hook is involved; a hook that funds the input is just the zero-cost
extreme. The note now states the sweep as the general rule, names the
payerIsUser = false and native-input cases OZ called out, and drops
TRANSFER from the funding list since it pays out of the router.
OZ 2.3.0 audit N-05.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@dianakocsis
dianakocsis merged commit 60bbdf8 into dev-2.3.0 Oct 1, 2026
7 checks passed
@dianakocsis
dianakocsis deleted the fix/oz-2.3.0-audit-findings branch October 1, 2026 23:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants