Skip to content

feat: Universal Router 2.3.0 release branch - #512

Open
dianakocsis wants to merge 10 commits into
mainfrom
dev-2.3.0
Open

dianakocsis wants to merge 10 commits into
mainfrom
dev-2.3.0

Conversation

@dianakocsis

@dianakocsis dianakocsis commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Important

Do not merge until the 2.3.0 audit clears and v4-periphery has landed on its main (see Before merging to main). This is the release PR for Universal Router 2.3.0 and stays open as the integration view of the release.

Review as a two-dot diff: git diff origin/main..origin/dev-2.3.0.

What's in it

main was rolled back to the deployed code by #514, which reverted #491 and #497. This branch forked before that revert and keeps both, so they show up here even though they first merged in August.

PR Change
#491 + #502 Nested V4_SWAP, gated behind an explicit opt-in (M-01). #491 let V4_SWAP run inside a PoolManager unlock opened by another contract whenever poolManager.isUnlocked() was true. V4 deltas accrue under the router's address for the whole unlock, so one call's unpaid debt could be settled from a later caller's funds. #502 moves consent onto a new entrypoint, executeNested(commands, inputs, deadline): the nested branch requires its transient flag, and plain execute / executeSigned revert with NestedExecutionNotPermitted. For callers that opt in, the exposure is accepted, not enforced, and IUniversalRouter NatSpec documents both directions.
#497 Bounded command input decoding. Adds checkInputLength minimum-head checks on the inline-assembly decoders, a bounded decodePermitBatch, a BytesLib.toLengthOffset element-size overload, and minimum-length checks in V3ToV4Migrator. Also adds UNWRAP_WETH_EXACT (0x0f) and widens SWEEP's amountMin to uint256.
#504 Comment-only. Documents in V2SwapRouter and V3SwapRouter that balanceOf-derived trade sizes count donations, which can trip minHopPriceX36.
#507 Transient storage slots from a compact table. Replaces the keccak-derived slots with small literals in TransientSlots, recovering bytecode.
lib/v4-periphery a7af5b34 → 0cc6e164. Adds v4-periphery #593: swap param decoders use abi.decode, so they are bounded by params.length, not calldatasize(). Also keeps #584's hook-funded input tolerance, which v4-periphery main reverted in #601 (0cc6e164 forked before that revert).

Queued into this branch

PR Change
#508 RESOLVE command for execution-time amounts. Also moves lib/v4-periphery to ed72ce4, the dev-ur-2.3.0 tip. That adds v4-periphery #599's _mapSwapAmount hook and v4-periphery main's permissioned-pool fixes #602 and #603. It also syncs foundry.lock with the submodule pins.
#509 Protocol fee update commands for V4 and V3 pools. Stacked on #508.
#522 Payments refactor. No behavior change, −435 bytes.

Bytecode

UniversalRouter runtime, measured with forge build --sizes:

Runtime Free under 24,576
main (543e1a1) 23,233 1,343
this branch (5e74759) 23,657 919
with #508 (f157392) 24,053 523
with #508 + #509 (4ccc495) 24,428 148

optimizer_runs is already 1 for UniversalRouter.sol. #522's 435 bytes were measured against this branch alone; the combined figure needs re-measuring once the stack is together.

Before merging to main

Both repos only squash-merge and delete the head branch, so the audited v4-periphery commit will not end up on v4-periphery's main.

  1. Audit clean. Tag the audited commits in both repos, so they stay reachable after their branches are deleted.
  2. Merge v4-periphery #605 (dev-ur-2.3.0 → main), with nothing else landing on its main first.
  3. Repoint lib/v4-periphery and foundry.lock to the squash commit on v4-periphery main. Confirm its tree equals the audited commit's (git rev-parse <squash>^{tree}), then re-check bytecode and tests.
  4. Merge this PR.

When merging main forward into this branch, revert #514 in the same merge. Otherwise the merge re-applies the revert and silently deletes #491/#497 from this branch.

Testing

V4SwapWithinUnlock.t.sol (opt-in gating, caller isolation, sub-plan flag persistence), V4NestedUnlockCalldataSmuggling.t.sol (struct-offset redirect, both paths), V4NestedMemberSmuggle.t.sol (member-offset redirect), TransientSlots.t.sol (slot distinctness, cross-write fuzz), UniversalRouter.t.sol (input bounding), RouteSigner.t.sol (calldata smuggling).

AI-Generated Description

[!IMPORTANT]
Do not merge until the 2.3.0 audit clears and v4-periphery has landed on its main (see Before merging to main). This is the release PR for Universal Router 2.3.0 and stays open as the integration view of the release.
Review as a two-dot diff: git diff origin/main..origin/dev-2.3.0.

What's in it

main was rolled back to the deployed code by #514, which reverted #491 and #497. This branch forked before that revert and keeps both, so they show up here even though they first merged in August.

PR Change
#491 + #502 Nested V4_SWAP, gated behind an explicit opt-in (M-01). #491 let V4_SWAP run inside a PoolManager unlock opened by another contract whenever poolManager.isUnlocked() was true. V4 deltas accrue under the router's address for the whole unlock, so one call's unpaid debt could be settled from a later caller's funds. #502 moves consent onto a new entrypoint, executeNested(commands, inputs, deadline): the nested branch requires its transient flag, and plain execute / executeSigned revert with NestedExecutionNotPermitted. For callers that opt in, the exposure is accepted, not enforced, and IUniversalRouter NatSpec documents both directions.
#497 Bounded command input decoding. Adds checkInputLength minimum-head checks on the inline-assembly decoders, a bounded decodePermitBatch, a BytesLib.toLengthOffset element-size overload, and minimum-length checks in V3ToV4Migrator. Also adds UNWRAP_WETH_EXACT (0x0f) and widens SWEEP's amountMin to uint256.
#504 Comment-only. Documents in V2SwapRouter and V3SwapRouter that balanceOf-derived trade sizes count donations, which can trip minHopPriceX36.
#507 Transient storage slots from a compact table. Replaces the keccak-derived slots with small literals in TransientSlots, recovering bytecode.
lib/v4-periphery a7af5b34 → 0cc6e164. Adds v4-periphery #593: swap param decoders use abi.decode, so they are bounded by params.length, not calldatasize(). Also keeps #584's hook-funded input tolerance, which v4-periphery main reverted in #601 (0cc6e164 forked before that revert).

Queued into this branch

PR Change
#508 RESOLVE command for execution-time amounts. Also moves lib/v4-periphery to ed72ce4, the dev-ur-2.3.0 tip. That adds v4-periphery #599's _mapSwapAmount hook and v4-periphery main's permissioned-pool fixes #602 and #603. It also syncs foundry.lock with the submodule pins.
#509 Protocol fee update commands for V4 and V3 pools. Stacked on #508.
#522 Payments refactor. No behavior change, −435 bytes.

Bytecode

UniversalRouter runtime, measured with forge build --sizes:

Runtime Free under 24,576
main (543e1a1) 23,233 1,343
this branch (5e74759) 23,657 919
with #508 (f157392) 24,053 523
with #508 + #509 (4ccc495) 24,428 148
optimizer_runs is already 1 for UniversalRouter.sol. #522's 435 bytes were measured against this branch alone; the combined figure needs re-measuring once the stack is together.

Before merging to main

Both repos only squash-merge and delete the head branch, so the audited v4-periphery commit will not end up on v4-periphery's main.

  1. Audit clean. Tag the audited commits in both repos, so they stay reachable after their branches are deleted.
  2. Merge v4-periphery #605 (dev-ur-2.3.0 → main), with nothing else landing on its main first.
  3. Repoint lib/v4-periphery and foundry.lock to the squash commit on v4-periphery main. Confirm its tree equals the audited commit's (git rev-parse <squash>^{tree}), then re-check bytecode and tests.
  4. Merge this PR.
    When merging main forward into this branch, revert revert: undo #491 and #497 input-bounding and nested-unlock changes #514 in the same merge. Otherwise the merge re-applies the revert and silently deletes feat: allow V4_SWAP within an existing PoolManager unlock #491/fix: bound router command input decoding #497 from this branch.

Testing

V4SwapWithinUnlock.t.sol (opt-in gating, caller isolation, sub-plan flag persistence), V4NestedUnlockCalldataSmuggling.t.sol (struct-offset redirect, both paths), V4NestedMemberSmuggle.t.sol (member-offset redirect), TransientSlots.t.sol (slot distinctness, cross-write fuzz), UniversalRouter.t.sol (input bounding), RouteSigner.t.sol (calldata smuggling), Resolve.t.sol (register lifecycle, reverting/short-returning resolvers, return bomb, cross-execute clearing), ResolveV4.t.sol (v4 exact-input/output with resolved amounts), ResolvedAmount.t.sol (slot allocation), V4ProtocolFeeUpdate.t.sol (poke reaches adapter, idempotent repoke, reverting adapter), V3ProtocolFeeUpdate.t.sol (poke via factory owner), ProtocolFeeUpdate.fork.t.sol (live mainnet adapters).

AI-Generated Description Release branch for Universal Router 2.3.0. Six PRs plus a submodule bump, spanning security hardening, new commands, and a bytecode-recovery refactor. ## #502 — gate nested `V4_SWAP` behind an opt-in entrypoint (M-01) #491 let a `V4_SWAP` nest inside a PoolManager unlock opened by another contract whenever `poolManager.isUnlocked()` was true — inferring consent from chain state. V4 deltas accrue under the router's address for the lifetime of that unlock while the router's own lock clears between calls, so one call could leave debt that a later call's `SETTLE_ALL` or `OPEN_DELTA` settlement paid from a different caller's funds. Consent now lives on the entrypoint: `executeNested(commands, inputs, deadline)` sets a transient flag that the nested branch requires. Plain `execute` / `executeSigned` revert `NestedExecutionNotPermitted` instead of silently nesting. The exposure is **accepted, not enforced**, for callers that do opt in — an opted-in composer owns its own delta hygiene. The `IUniversalRouter` NatSpec documents both directions: debt you leave can be paid by a later caller, and credit you leave can be `TAKE`n by anyone who opts in before your next call. Also carried: the submodule bump to decoders that use `abi.decode`, so v4 swap params are bounded by `params.length` rather than `calldatasize()`. ## #497 — bound router command input decoding - `checkInputLength` minimum-ABI-head checks on all inline-assembly decoders - `decodePermitBatch` validates struct offset, details offset, and element count against input bounds - `BytesLib.toLengthOffset` element-size overload for `PERMIT2_TRANSFER_FROM_BATCH` - `V3ToV4Migrator` minimum-length checks before selector reads - `UNWRAP_WETH_EXACT` (`0x0f`): new command that unwraps an exact amount of WETH, reverting if balance is insufficient - `SWEEP` widens `amountMin` from `uint160` to `uint256` ## #504 — document the per-hop bound donation limitation Comment-only. Notes in `V2SwapRouter` and `V3SwapRouter` that `balanceOf`-derived trade sizes count third-party donations, which enlarges the apparent trade and can trip `minHopPriceX36`. Donations stay recoverable via `skim()` / `SWEEP`. ## #507 — allocate transient storage slots from a compact table Moves every transient storage slot (`Locker`, `MaxInputAmount`, `RouteSigner`'s three context slots, `NestedUnlock`) from keccak-derived 32-byte hashes to small literals in a `TransientSlots` table. Recovers ~700 bytes of runtime bytecode — each `PUSH32` of a keccak constant replaced by a `PUSH1`. ## #508 — `RESOLVE` command for execution-time amount resolution Adds the `RESOLVE` command (`0x15`). Input is `abi.encode(address resolver, bytes context)`; the router performs a bounded `staticcall` to `resolver.resolveAmount(context)` (`IAmountResolver`) and stores the returned word in a transient register. A later command consumes it by passing `Constants.USE_RESOLVED_AMOUNT` (`type(uint128).max`) in an amount field. Supported fields: v2/v3 swap `amountIn`/`amountOut`, `TRANSFER`'s value, and v4 swap action amounts. ## #509 — protocol fee update commands for V4 and V3 pools Adds `V4_PROTOCOL_FEE_UPDATE` (`0x16`) and `V3_PROTOCOL_FEE_UPDATE` (`0x17`). The V4 command takes `abi.encode(PoolKey)` and reads `poolManager.protocolFeeController()` to find the fee adapter; the V3 command takes `abi.encode(address pool)` and reads `IUniswapV3Factory.owner()`. Both call `triggerFeeUpdate` on the discovered adapter. ## #522 — payments refactor `pay`, `payPortion`, `sweep`, `unwrapWETH9` and `unwrapWETH9Exact` now go through three private helpers: `_transfer`, `_balanceOf` and `_unwrap`. Recovers ~435 bytes at `optimizer_runs = 1`. No functional change. ## #523 + #524 — OZ 2.3.0 audit fixes Two rounds of fixes for the OpenZeppelin 2.3.0 audit findings: - **L-01**: Sub-plan failing with `NestedExecutionNotPermitted` re-thrown even under `FLAG_ALLOW_REVERT` - **L-03**: Map `PERMIT2_TRANSFER_FROM` and batch detail amounts through the resolved-amount register - **L-04**: Document sub-plan resolved-amount register sharing - **N-02**: Correct V2 donation note - **N-03**: Deadline-free `executeNested` overload - **N-04**: V2 exact-output delivery check at recipient; share-rounding token note - **N-05**: README documents hook-funded exact-output input retention - **N-06**: `BytesLib.toLengthOffset` whole-word guard comparison - **N-07**: README lists `FLAG_ALLOW_REVERT`-affected commands - **N-09**: Fix stale command range comment in `Commands.sol` - **N-10**: `TransientSlots` file-level constants as single source of truth - **N-11**: Call `protocolFeeController()` on `poolManager` directly ## Changes - **`UniversalRouter.sol`**: Add `executeNested` entrypoint (with and without deadline) that sets `NestedUnlock` flag; clear resolved-amount register on top-level exit - **`Dispatcher.sol`**: Add `checkInputLength` bounds checks, `decodePermitBatch` bounded decoder, nested `V4_SWAP` opt-in gate via `NestedUnlock`, `UNWRAP_WETH_EXACT` command, widen `SWEEP` `amountMin` to `uint256`, dispatch `RESOLVE` and protocol-fee-update commands, wire `ResolvedAmount.map()` into V2/V3 swap amounts and `TRANSFER`, re-throw `NestedExecutionNotPermitted` from sub-plans regardless of `FLAG_ALLOW_REVERT`, map `PERMIT2_TRANSFER_FROM` and `WRAP_ETH`/`UNWRAP_WETH_EXACT` amounts through resolved register - **`Commands.sol`**: Replace `0x0f` placeholder with `UNWRAP_WETH_EXACT`, define `RESOLVE = 0x15`, `V4_PROTOCOL_FEE_UPDATE = 0x16`, `V3_PROTOCOL_FEE_UPDATE = 0x17`; fix command-type range comment - **`Constants.sol`**: Add `USE_RESOLVED_AMOUNT = type(uint128).max` sentinel with full field list documentation - **`Payments.sol`**: Add `unwrapWETH9Exact`, refactor payment helpers into `_transfer`, `_balanceOf`, `_unwrap` - **`Permit2Payments.sol`**: Rewrite `permit2TransferFrom(batch)` to transfer one detail at a time for resolved-amount mapping - **`BytesLib.sol`**: Add `toLengthOffset(bytes, uint256, uint256)` element-size overload with whole-word bounds guard - **`V3ToV4Migrator.sol`**: Add minimum-length checks in `_checkV3PermitCall`, `_checkV3PositionManagerCall`, `_checkV4PositionManagerCall` - **`IUniversalRouter.sol`**: Add `executeNested` interface (both overloads) and update `signedRouteContext` NatSpec - **`IAmountResolver.sol`** (new): Interface for execution-time amount resolvers - **`IV4FeeAdapter.sol`** / **`IV3FeeAdapter.sol`** (new): Minimal interfaces for fee adapter `triggerFeeUpdate` - **`NestedUnlock.sol`** (new): Transient storage flag for nested unlock opt-in - **`ResolvedAmount.sol`** (new): Transient register for resolved amounts with `set`, `get`, `reset`, `map` helpers - **`TransientSlots.sol`** (new): Central allocation table mapping seven named constants to slots `0x01`–`0x07`, now file-level constants - **`V4SwapRouter.sol`**: Override `_mapSwapAmount`, `_mapSettleAmount`, `_mapTakeAmount` to substitute the resolved-amount register - **`ChainedActions.sol`**: Map `ACROSS_V4_DEPOSIT_V3` `inputAmount` through `ResolvedAmount.map` - **`Locker.sol`**, **`MaxInputAmount.sol`**, **`RouteSigner.sol`**: Import from `TransientSlots` (single source of truth) - **`V2SwapRouter.sol`**: Move delivery check into hop loop for exact-input/output sharing; document donation limitation and share-rounding tokens - **`V3SwapRouter.sol`**: Document donation limitation (comment-only) - **`lib/v4-periphery`**: Bump `a7af5b34` → `ed72ce4` (abi.decode swap param decoders, `_mapSwapAmount` hook, OZ audit fixes) - **`README.md`**: Update command table for `0x0e`–`0x17`; document `UNWRAP_WETH_EXACT`, `RESOLVE`, protocol fee update commands, sentinel-aware field lists, `FLAG_ALLOW_REVERT` scope, hook-funded exact-output notes ## Blocking: unmerged upstream dependency `lib/v4-periphery` moves to `ed72ce4`, the head of `dev-ur-2.3.0`. **This PR should not merge to `main` until that branch merges upstream** and the pin is moved to the merged commit. ## Testing `V4SwapWithinUnlock.t.sol` (opt-in gating, caller isolation, sub-plan flag persistence), `V4NestedUnlockCalldataSmuggling.t.sol` (struct-offset redirect, both paths), `V4NestedMemberSmuggle.t.sol` (member-offset redirect), `TransientSlots.t.sol` (slot distinctness and cross-write fuzz), `UniversalRouter.t.sol` (input-bounding), `RouteSigner.t.sol` (calldata smuggling), `Resolve.t.sol` (register lifecycle, reverting/short-returning resolvers, return bomb, cross-execute clearing), `ResolveV4.t.sol` (v4 exact-input/output with resolved amounts), `ResolvedAmount.t.sol` (slot allocation), `V4ProtocolFeeUpdate.t.sol` (poke reaches adapter, idempotent repoke, reverting adapter), `V3ProtocolFeeUpdate.t.sol` (poke via factory owner), `ProtocolFeeUpdate.fork.t.sol` (live mainnet adapters), `V2ExactOutputDelivery.t.sol` (fee-on-transfer and withholding tokens), `BytesLib.t.sol` (wrapping index and out-of-bounds fuzz).

Audit finding L-01 (Low). The V2 per-hop bound divides by
`balanceOf(pair) - reserveInput`, which counts tokens any third party can
transfer to the pair. A larger apparent trade earns a worse average rate, so
a donation drives the measured price under minHopPriceX36 and reverts a
bounded route. The pair's permissionless skim() returns the donation
afterwards, so censorship costs the attacker only gas and ordering.

No funds are at risk and the bound never lets bad execution through; the
failure is a spurious revert, which is why this is Low.

Not fixing it in this release. The correct primitive requires routing to emit
different per-hop values, and the encoding migration needs deciding first --
the field's meaning would change without its type changing, so a stale caller
would be silently misread rather than rejected. Fixing in the next version.

Also notes the related V3 exposure: when amountIn == CONTRACT_BALANCE the
router seeds amountIn from its own balance, so a donation to the router
enlarges the trade the same way and SWEEP takes an arbitrary recipient, which
makes it recoverable.

Comments only. Bytecode is unchanged at 23,705 bytes, matching the existing
snapshot.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dianakocsis
dianakocsis requested a review from a team as a code owner September 14, 2026 17:25
@dianakocsis dianakocsis changed the title docs: note the per-hop bound donation limitation in V2 and V3 (#504) dev-2.3.0 Sep 14, 2026
* fix: gate nested V4_SWAP behind an explicit opt-in entrypoint

#491 ran V4 actions inside a foreign PoolManager unlock whenever
poolManager.isUnlocked() was true, inferring consent from chain state. All V4
deltas accrue under the router's address for the lifetime of that unlock while
the router's own locker clears between external calls, so one call could leave
unpaid debt that a later call's SETTLE_ALL or OPEN_DELTA settlement paid from a
different caller's funds (audit finding M-01).

Consent now lives on the entrypoint. executeNested sets a transient flag for the
duration of the call and the nested branch requires it, so only a caller that
opened the surrounding unlock can reach the path. It cannot live in the payload:
EXECUTE_SIGNED_TYPEHASH commits to keccak256(commands), and a command byte would
let the route author, rather than the composer whose capital is at risk, make the
decision. Plain execute now reverts NestedExecutionNotPermitted instead.

An opted-in caller owns the delta hygiene the router can no longer guarantee on
its behalf; a permissionless function reaching this entrypoint can have its own
capital drained. This is accepted rather than enforced.

Signed routes cannot nest in this version, which is a regression against main
where isUnlocked() auto-detection allowed it. executeSignedNested would restore
it but costs 252 bytes against 473 remaining, and the RESOLVE proposal needs the
same headroom.

Also carries the calldata re-encode: the v4 parameter decoders follow struct and
member offsets bounded only by calldatasize(), so forwarding a slice of
transaction calldata let them read bytes the signature never covered.
executeV4SwapWithinUnlock re-enters through the ABI encoder so calldata ends
where the input ends. This removes reachability from the router's nested path
only -- the decoders themselves are unchanged and are fixed separately. It stays
load-bearing under the opt-in, since an attacker can opt themselves in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: bump v4-periphery to abi.decode swap param decoders (#505)

* chore: bump v4-periphery to abi.decode swap param decoders

Points lib/v4-periphery at Uniswap/v4-periphery#593 (0cc6e164), which
replaces the assembly struct-pointer assignment in the four V4 swap
param decoders with abi.decode, so dynamic members are bounded by
params.length rather than calldatasize().

Costs 408 bytes of UniversalRouter runtime size at optimizer_runs=1,
leaving 76 bytes of headroom under the 24,576 limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: update gas snapshots for abi.decode swap decoders

Regenerated after the v4-periphery bump. All 66 changed entries are
gasUsed only; no calldataByteLength changed and no functional test
behavior changed.

Deltas run -168 to +480. The multi-swap UX benchmarks regress ~30 gas
per swap; most individual-command benchmarks improve ~30 gas. Both
directions are optimizer layout shifts at optimizer_runs = 1 rather
than a uniform per-swap cost -- the hardhat V4 coverage is migration
and mint, not V4 swaps, so the decoder's own swap cost is not
exercised here.

Also bumps the UniversalRouter bytecode size snapshot to 24,500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: drop the nested-unlock calldata re-encode, redundant under #505 (#513)

* refactor: drop the nested-unlock calldata re-encode, redundant under #505

The self-call `executeV4SwapWithinUnlock` existed to cap `calldatasize()` at
the input boundary, which mattered only because the old assembly swap-param
decoders followed struct and member offsets bounded by `calldatasize()`
rather than by `params.length`. The v4-periphery bump in #505 (now on this
branch) replaced those with `abi.decode`, which bounds the struct offset and
every dynamic member against the input length. The decoders end the read at
the input boundary on their own, on the nested path as much as the ordinary
one, so the re-encode is redundant.

The nested `V4_SWAP` branch decodes directly from its calldata slice again,
as it did before the opt-in commit.

Regression coverage moves with it: `V4NestedMemberSmuggle.t.sol` drives the
member-offset smuggle, where a redirected `path` member carries a whole
`PathKey` (hooks address included) sourced from bytes past
`inputs[0].length` -- the variant a struct-offset-only fix misses. It is
blocked. The existing struct-offset tests are unchanged and still pass, and
the obsolete `NotSelf` self-call test is dropped with the function.

The property is now guarded by the decoders plus these tests rather than by
the re-encode, so a future submodule bump that reintroduces unbounded
decoding fails loudly instead of silently.

Bytecode: 24,500 -> 24,353 runtime, leaving 223 bytes under EIP-170.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: update hardhat gas snapshots for the re-encode removal

Dropping `executeV4SwapWithinUnlock` removes one entry from the router's
external selector dispatch table, so every benchmark that enters the router
pays one fewer comparison. All 31 changed entries fall by exactly 22 gas, or
44 where a sub-plan enters twice. No `calldataByteLength` changed and no
benchmark regressed.

Values taken from the CI run on this branch rather than regenerated locally,
so they match the pinned Foundry 1.4.3 / solc toolchain CI uses instead of a
local 1.5.1 build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: warn that nested credit is takeable, not just debt payable

The executeNested NatSpec covered one direction of the shared delta account:
an unsettled debt left by one call being paid by a later call's SETTLE_ALL or
OPEN_DELTA out of that later caller's funds. It did not cover the mirror
case, and the surrounding reasoning did not either.

Positive delta left under the router is not reserved for whoever created it.
Any contract that gains control while the router's own lock is clear -- an
attacker's hook or callback between two nested calls -- can opt itself in via
this same public entrypoint and TAKE that credit. The argument that the set of
contracts wanting nesting and the set exposed to delta sharing are disjoint
does not reach this variant: its victim is a composer that did open its own
unlock and does want the feature.

A hook firing mid-swap cannot do it, since re-entering execute() hits
isNotLocked and reverts ContractLocked. It requires the composer to pass
control to an untrusted contract between two router calls while a delta is
open, which is why the exposure stays low. Documenting it rather than
enforcing it keeps the existing accepted-risk stance, now stated in both
directions.

Raised by an automated audit run against this branch's code as
"Foreign nested callers can consume shared V4 router deltas".

Comment-only: NatSpec is stripped before codegen, so runtime size and gas are
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: trim the nested credit warning to two lines

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: fix stale re-encode reference in smuggling test

#513 removed the re-encode; abi.decode is what bounds the offset now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot changed the title dev-2.3.0 fix: gate nested V4_SWAP behind opt-in entrypoint and document donation vectors Sep 15, 2026
dianakocsis added a commit that referenced this pull request Sep 16, 2026
…514)

* revert: bound router command input decoding (#497)

Reverts d203e7f.

This change was deployed to mainnet at 0x0542093271A31f6FC1DADB232bd59eeb27de780F
but was never intended to ship. Removes the checkInputLength calldata bounds
checks, the BytesLib hardening, the SWEEP uint160 truncation fix, the
UNWRAP_WETH_EXACT command (0x0f), and unwrapWETH9Exact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* revert: allow V4_SWAP within an existing PoolManager unlock (#491)

Reverts 9e9a780.

This change was deployed to mainnet at 0x0542093271A31f6FC1DADB232bd59eeb27de780F
but was never intended to ship. It is the root cause of audit finding M-01:
V4 deltas accrue under the router regardless of which contract called execute(),
so within one foreign unlock, one call can leave debt that a later call settles
from a different msgSender().

Removing the fast path restores the pre-#491 behaviour: a nested V4_SWAP reverts
with AlreadyUnlocked. This supersedes #502, #512 and #513, which existed only to
gate the fast path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: regenerate UniversalRouter bytecode size snapshot

23705 -> 23303 after reverting #491 and #497.

The hardhat gas snapshots under test/integration-tests/gas-tests/__snapshots__/
are deliberately left untouched: they require a mainnet fork (FORK_URL, block
20010000) to regenerate and cannot be derived by hand. They must be regenerated
with 'UPDATE_SNAPSHOT=1 yarn test:hardhat --grep gas' before this merges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: regenerate hardhat gas snapshots after reverting #491 and #497

65 snapshots updated across 5 suites. One snapshot removed:
'UNWRAP_WETH_EXACT partial amount', whose command (0x0f) no longer exists
after the #497 revert.

Regenerated with UPDATE_SNAPSHOT=1 against a mainnet fork at block 20010000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build: bump v4-periphery to main with the #584 revert

Moves lib/v4-periphery 07336f2 -> a7af5b3, which is v4-periphery main after
#601 merged (revert of #584, tolerate hook-funded input on exact-output swaps).

#564's exact-output partial-fill revert is retained; the only src/ change
against the previous pin is V4Router.sol (+4/-23).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: regenerate bytecode snapshot after v4-periphery bump

23303 -> 23233. Hardhat gas snapshots unchanged: the #584 revert only removes
hook-funded exact-output paths, which the gas suite does not exercise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* revert: undo #491 and #497 input-bounding and nested-unlock changes (#514)

* revert: bound router command input decoding (#497)

Reverts d203e7f.

This change was deployed to mainnet at 0x0542093271A31f6FC1DADB232bd59eeb27de780F
but was never intended to ship. Removes the checkInputLength calldata bounds
checks, the BytesLib hardening, the SWEEP uint160 truncation fix, the
UNWRAP_WETH_EXACT command (0x0f), and unwrapWETH9Exact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* revert: allow V4_SWAP within an existing PoolManager unlock (#491)

Reverts 9e9a780.

This change was deployed to mainnet at 0x0542093271A31f6FC1DADB232bd59eeb27de780F
but was never intended to ship. It is the root cause of audit finding M-01:
V4 deltas accrue under the router regardless of which contract called execute(),
so within one foreign unlock, one call can leave debt that a later call settles
from a different msgSender().

Removing the fast path restores the pre-#491 behaviour: a nested V4_SWAP reverts
with AlreadyUnlocked. This supersedes #502, #512 and #513, which existed only to
gate the fast path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: regenerate UniversalRouter bytecode size snapshot

23705 -> 23303 after reverting #491 and #497.

The hardhat gas snapshots under test/integration-tests/gas-tests/__snapshots__/
are deliberately left untouched: they require a mainnet fork (FORK_URL, block
20010000) to regenerate and cannot be derived by hand. They must be regenerated
with 'UPDATE_SNAPSHOT=1 yarn test:hardhat --grep gas' before this merges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: regenerate hardhat gas snapshots after reverting #491 and #497

65 snapshots updated across 5 suites. One snapshot removed:
'UNWRAP_WETH_EXACT partial amount', whose command (0x0f) no longer exists
after the #497 revert.

Regenerated with UPDATE_SNAPSHOT=1 against a mainnet fork at block 20010000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build: bump v4-periphery to main with the #584 revert

Moves lib/v4-periphery 07336f2 -> a7af5b3, which is v4-periphery main after
#601 merged (revert of #584, tolerate hook-funded input on exact-output swaps).

#564's exact-output partial-fill revert is retained; the only src/ change
against the previous pin is V4Router.sol (+4/-23).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: regenerate bytecode snapshot after v4-periphery bump

23303 -> 23233. Hardhat gas snapshots unchanged: the #584 revert only removes
hook-funded exact-output paths, which the gas suite does not exercise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: record 2.1.2 deployment addresses (#516)

* chore: record 2.1.2 deployment addresses

Record the UniversalRouter 2.1.2 addresses deployed across all 24 chains.

Every address was verified onchain before recording: 24,380-byte runtime,
all ten constructor parameters matching the deploy script, immutables
embedded in the deployed bytecode, and source verified (Etherscan V2 where
available, Sourcify otherwise; Tempo on its own Sourcify instance).

Four chains additionally record an UnsupportedProtocolV2_1_2 entry. Their
deploy scripts left `unsupported` unset, so the script deployed a throwaway
revert stub first, which consumed a nonce and shifted the router address.
Recording it explains why those routers do not share an address with the
rest of their nonce group.

arc.json, megaeth.json and robinhood.json are new; these chains had never
been recorded. Their pre-existing routers were read from the SDK and
confirmed onchain before being included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(script): point base-sepolia and unichain-sepolia at live v4 deployments

Both scripts referenced PoolManager and PositionManager addresses that have
no recent onchain activity. A router deployed against them cannot execute
any v4 command.

Confirmed by log activity rather than by documentation, which has been
unreliable here. On Base Sepolia the previous PoolManager
(0xf7F5aB3D) has produced no logs in the last 50k blocks, while
0x05E73354 is actively emitting. The same holds on Unichain Sepolia for
0x9cB26A71 versus 0x00B036B5.

Sepolia needed no change; it was corrected on main in 020e1b7.

These addresses are what UniversalRouter 2.1.2 was actually deployed with
on both chains, so main's scripts now describe what is live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(script): add arc, megaeth and robinhood params; fill in unsupported

Two gaps surfaced while deploying 2.1.2.

Ink, Unichain, Worldchain and Zora left `unsupported` unset, so each deploy
created a fresh UnsupportedProtocol stub before the router. That consumed a
nonce and shifted the router off the address its nonce group would
otherwise share, and left an orphaned stub that nothing references. Filling
in the stubs deployed during 2.1.2 stops the next deploy repeating it. Each
address was confirmed onchain to be a 60-byte contract that reverts with
UnsupportedProtocolError.

Arc, MegaETH and Robinhood had no deploy parameters on main at all; they
were added on release/2.1.x and never ported. Without them a deploy from
main silently skips those chains. Ported with permissionsAdapterFactory
added to match the RouterParameters shape on main.

All 24 chains' parameters now match the constructor arguments of the
routers actually deployed onchain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: fix forge fmt violation in PermissionedV4 test

Pre-existing on main and unrelated to this branch, but it fails
`yarn lint:check` and so would show a red CI run on any PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Revert "style: fix forge fmt violation in PermissionedV4 test"

This reverts commit 8ec8ca8.

* chore: drop the mislabelled 2.1.1 entry for megaeth

0x47837eb80db5908eabba9105626d9b348bea7b02 was recorded as
UniversalRouterV2_1_1, taken from universal-router-sdk's CHAIN_CONFIGS. It
cannot be 2.1.1: it was deployed 2026-01-30, and tag 2.1.1 was not cut until
2026-05-22.

It is on the 2.1 line — SPOKE_POOL() resolves, which the 2.0 router on that
chain does not — but at 21,738 bytes it is well short of 2.1.1's 24,546,
consistent with a build predating the per-hop slippage work. The 2.1.1 release
notes warn about exactly this: "Do not identify existing 2.1.0 bytecode as
2.1.1." There is no 2.1.0 tag to bytecode-match against, so rather than invent
a label the entry is dropped.

UniversalRouterV2 (0x48fd0352...) is kept: verified on Etherscan as
UniversalRouter, no SPOKE_POOL(), and 19,499 bytes matching the 2.0 routers on
ink, tempo, unichain and zora.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(script): add HyperEVM (999) deploy parameters (#517)

Used for the 2.2.0 deployment at 0x9aFe3C497e19501DB228F28CdBdD29bC98F65DBa on 2026-09-18
(tx 0xd9eda6c912e49c0ce7e15bfd7a6d5dc91de796cc08231aec80df191341244f34), built at tag 2.2.0 (64027f3).
Across SpokePool and PermissionsAdapterFactory wired; reuses the UnsupportedProtocol at 0xEEE3…4BcF.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: David <274080779+david-uniswap@users.noreply.github.com>
@dianakocsis dianakocsis changed the title fix: gate nested V4_SWAP behind opt-in entrypoint and document donation vectors dev 2.3.0 Sep 22, 2026
ccashwell and others added 2 commits September 28, 2026 13:57
* fix: gate nested V4_SWAP behind an explicit opt-in entrypoint

#491 ran V4 actions inside a foreign PoolManager unlock whenever
poolManager.isUnlocked() was true, inferring consent from chain state. All V4
deltas accrue under the router's address for the lifetime of that unlock while
the router's own locker clears between external calls, so one call could leave
unpaid debt that a later call's SETTLE_ALL or OPEN_DELTA settlement paid from a
different caller's funds (audit finding M-01).

Consent now lives on the entrypoint. executeNested sets a transient flag for the
duration of the call and the nested branch requires it, so only a caller that
opened the surrounding unlock can reach the path. It cannot live in the payload:
EXECUTE_SIGNED_TYPEHASH commits to keccak256(commands), and a command byte would
let the route author, rather than the composer whose capital is at risk, make the
decision. Plain execute now reverts NestedExecutionNotPermitted instead.

An opted-in caller owns the delta hygiene the router can no longer guarantee on
its behalf; a permissionless function reaching this entrypoint can have its own
capital drained. This is accepted rather than enforced.

Signed routes cannot nest in this version, which is a regression against main
where isUnlocked() auto-detection allowed it. executeSignedNested would restore
it but costs 252 bytes against 473 remaining, and the RESOLVE proposal needs the
same headroom.

Also carries the calldata re-encode: the v4 parameter decoders follow struct and
member offsets bounded only by calldatasize(), so forwarding a slice of
transaction calldata let them read bytes the signature never covered.
executeV4SwapWithinUnlock re-enters through the ABI encoder so calldata ends
where the input ends. This removes reachability from the router's nested path
only -- the decoders themselves are unchanged and are fixed separately. It stays
load-bearing under the opt-in, since an attacker can opt themselves in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: bump v4-periphery to abi.decode swap param decoders (#505)

* chore: bump v4-periphery to abi.decode swap param decoders

Points lib/v4-periphery at Uniswap/v4-periphery#593 (0cc6e164), which
replaces the assembly struct-pointer assignment in the four V4 swap
param decoders with abi.decode, so dynamic members are bounded by
params.length rather than calldatasize().

Costs 408 bytes of UniversalRouter runtime size at optimizer_runs=1,
leaving 76 bytes of headroom under the 24,576 limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: update gas snapshots for abi.decode swap decoders

Regenerated after the v4-periphery bump. All 66 changed entries are
gasUsed only; no calldataByteLength changed and no functional test
behavior changed.

Deltas run -168 to +480. The multi-swap UX benchmarks regress ~30 gas
per swap; most individual-command benchmarks improve ~30 gas. Both
directions are optimizer layout shifts at optimizer_runs = 1 rather
than a uniform per-swap cost -- the hardhat V4 coverage is migration
and mint, not V4 swaps, so the decoder's own swap cost is not
exercised here.

Also bumps the UniversalRouter bytecode size snapshot to 24,500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: drop the nested-unlock calldata re-encode, redundant under #505 (#513)

* refactor: drop the nested-unlock calldata re-encode, redundant under #505

The self-call `executeV4SwapWithinUnlock` existed to cap `calldatasize()` at
the input boundary, which mattered only because the old assembly swap-param
decoders followed struct and member offsets bounded by `calldatasize()`
rather than by `params.length`. The v4-periphery bump in #505 (now on this
branch) replaced those with `abi.decode`, which bounds the struct offset and
every dynamic member against the input length. The decoders end the read at
the input boundary on their own, on the nested path as much as the ordinary
one, so the re-encode is redundant.

The nested `V4_SWAP` branch decodes directly from its calldata slice again,
as it did before the opt-in commit.

Regression coverage moves with it: `V4NestedMemberSmuggle.t.sol` drives the
member-offset smuggle, where a redirected `path` member carries a whole
`PathKey` (hooks address included) sourced from bytes past
`inputs[0].length` -- the variant a struct-offset-only fix misses. It is
blocked. The existing struct-offset tests are unchanged and still pass, and
the obsolete `NotSelf` self-call test is dropped with the function.

The property is now guarded by the decoders plus these tests rather than by
the re-encode, so a future submodule bump that reintroduces unbounded
decoding fails loudly instead of silently.

Bytecode: 24,500 -> 24,353 runtime, leaving 223 bytes under EIP-170.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: update hardhat gas snapshots for the re-encode removal

Dropping `executeV4SwapWithinUnlock` removes one entry from the router's
external selector dispatch table, so every benchmark that enters the router
pays one fewer comparison. All 31 changed entries fall by exactly 22 gas, or
44 where a sub-plan enters twice. No `calldataByteLength` changed and no
benchmark regressed.

Values taken from the CI run on this branch rather than regenerated locally,
so they match the pinned Foundry 1.4.3 / solc toolchain CI uses instead of a
local 1.5.1 build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: allocate transient storage slots from a compact table

Move every transient storage slot the router uses (Locker, MaxInputAmount,
RouteSigner's three context slots, NestedUnlock) from keccak-derived
32-byte hashes to small literals allocated in a single TransientSlots
table. Each reference to a 32-byte constant costs a PUSH32 in the runtime
bytecode, so the hashed form spent roughly 700 bytes on nothing: transient
storage is private to this contract, so distinctness within the table is
the only requirement, and no inherited dependency touches transient
storage in the router's context.

Inline assembly only accepts literal constants, so each library keeps its
own literal; TransientSlotsTest pins every copy to the table and checks the
slots are distinct and do not alias at runtime.

Bytecode: 24,500 -> 23,804 (-696), leaving 772 bytes under the EIP-170
limit for the RESOLVE and protocol-fee commands in this release. Hardhat
gas snapshots regenerated: every changed entry fell by 76 to 288 gas
(optimizer layout at optimizer_runs = 1), no calldata sizes changed.

* test: rebaseline bytecode snapshot after rebase onto #513

Rebased onto fix/v4-nested-unlock-hardening, dropping the two commits that
#505's squash-merge already delivered to that branch. The new base also
carries #513, which removed the nested-unlock re-encode, so the baseline
moved from 24,500 to 24,353.

The slot table saves the same ~696 bytes against the new baseline:
24,353 -> 23,657, leaving 919 bytes under EIP-170.

Gas snapshots were reset to the base branch's values rather than merged
textually, since they are derived artifacts and the layout shifted again
under the new baseline. They are regenerated from this branch's CI run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: update hardhat gas snapshots for the slot table

Small literal slots replace keccak-derived ones, so each set/get/reset
pushes a 1-byte immediate instead of a 32-byte one. Every one of the 49
changed entries falls, by 128 to 288 gas. No calldataByteLength changed and
no benchmark regressed.

Values taken from this branch's CI run rather than regenerated locally, so
they match the Foundry 1.4.3 / solc toolchain CI pins instead of a local
1.5.1 build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: warn against transient state variables while slots are literals

solc >=0.8.28 allocates `transient` state variables from slot 0 upward,
which would collide with the literal slot table. Document that no
contract in the router's inheritance tree may declare one, and that the
only safe migration moves every slot at once and deletes the table.

Drop the TODOs in Locker and NestedUnlock: the `transient` keyword has
existed since 0.8.28, and they invited the one-at-a-time migration that
collides. Each slot constant already points to TransientSlots.

Also correct the bytecode saving to roughly 700 bytes (measured 696).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: dianakocsis <diana.kocsis@uniswap.org>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#519 brought main in as a squash merge, so git still saw main's commits
as new, including revert #514. A plain merge would re-apply that revert
and silently delete the #491/#497 work (Payments, BytesLib,
V3ToV4Migrator and their tests) in files where nothing conflicts.

Resolve to the dev-2.3.0 tree and take only what main has that dev-2.3.0
lacks, #520 and #521:

- deploy-addresses/hyperevm.json and its CLAUDE.md entry
- permissionsAdapterFactory for Base, Ink and HyperEVM

lib/v4-periphery stays at dev-2.3.0's 0cc6e16; the bump to dev-ur-2.3.0
happens in #508. With main now an ancestor, later merges of main will
not re-apply #514. Bytecode is unchanged at 23,657.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot changed the title dev 2.3.0 feat: Universal Router 2.3.0 release branch Sep 28, 2026
ccashwell and others added 5 commits September 29, 2026 21:24
* refactor(payments): share the transfer, balance and unwrap paths

pay, payPortion, payPortionFullPrecision and sweep each carried their own
ETH-or-ERC20 branch, and unwrapWETH9 and unwrapWETH9Exact duplicated the
withdraw-and-forward step. Route them through three private helpers:
_transfer, _balanceOf and _unwrap. Behavior is unchanged, including the
CONTRACT_BALANCE handling in pay, the InsufficientETH and InsufficientToken
errors in sweep, and the self-recipient short circuit in the unwraps.

At optimizer_runs = 1 every inlined SafeTransferLib site is paid for in
bytecode, so collapsing eight of them into two saves 435 bytes of
UniversalRouter runtime (23,657 to 23,222), headroom the audit fixes need.

* test: regenerate Hardhat gas snapshots for the payments helpers

Regenerated against the mainnet fork at block 20010000 with
UPDATE_SNAPSHOT=1 yarn test:hardhat (190 passing, 2 pending). 39 entries
move, all between +4 and +227: a payment command costs 52 to 106 gas more
from the extra internal jumps through _transfer, _balanceOf and _unwrap,
routes without one move by 4 gas from optimizer layout, and the UX
aggregate benchmarks move by 69 to 115 gas per swap.

* docs(payments): document _balanceOf's return value

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: dianakocsis <diana.kocsis@uniswap.org>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix: gate nested V4_SWAP behind an explicit opt-in entrypoint

#491 ran V4 actions inside a foreign PoolManager unlock whenever
poolManager.isUnlocked() was true, inferring consent from chain state. All V4
deltas accrue under the router's address for the lifetime of that unlock while
the router's own locker clears between external calls, so one call could leave
unpaid debt that a later call's SETTLE_ALL or OPEN_DELTA settlement paid from a
different caller's funds (audit finding M-01).

Consent now lives on the entrypoint. executeNested sets a transient flag for the
duration of the call and the nested branch requires it, so only a caller that
opened the surrounding unlock can reach the path. It cannot live in the payload:
EXECUTE_SIGNED_TYPEHASH commits to keccak256(commands), and a command byte would
let the route author, rather than the composer whose capital is at risk, make the
decision. Plain execute now reverts NestedExecutionNotPermitted instead.

An opted-in caller owns the delta hygiene the router can no longer guarantee on
its behalf; a permissionless function reaching this entrypoint can have its own
capital drained. This is accepted rather than enforced.

Signed routes cannot nest in this version, which is a regression against main
where isUnlocked() auto-detection allowed it. executeSignedNested would restore
it but costs 252 bytes against 473 remaining, and the RESOLVE proposal needs the
same headroom.

Also carries the calldata re-encode: the v4 parameter decoders follow struct and
member offsets bounded only by calldatasize(), so forwarding a slice of
transaction calldata let them read bytes the signature never covered.
executeV4SwapWithinUnlock re-enters through the ABI encoder so calldata ends
where the input ends. This removes reachability from the router's nested path
only -- the decoders themselves are unchanged and are fixed separately. It stays
load-bearing under the opt-in, since an attacker can opt themselves in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: bump v4-periphery to abi.decode swap param decoders

Points lib/v4-periphery at Uniswap/v4-periphery#593 (0cc6e164), which
replaces the assembly struct-pointer assignment in the four V4 swap
param decoders with abi.decode, so dynamic members are bounded by
params.length rather than calldatasize().

Costs 408 bytes of UniversalRouter runtime size at optimizer_runs=1,
leaving 76 bytes of headroom under the 24,576 limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: update gas snapshots for abi.decode swap decoders

Regenerated after the v4-periphery bump. All 66 changed entries are
gasUsed only; no calldataByteLength changed and no functional test
behavior changed.

Deltas run -168 to +480. The multi-swap UX benchmarks regress ~30 gas
per swap; most individual-command benchmarks improve ~30 gas. Both
directions are optimizer layout shifts at optimizer_runs = 1 rather
than a uniform per-swap cost -- the hardhat V4 coverage is migration
and mint, not V4 swaps, so the decoder's own swap cost is not
exercised here.

Also bumps the UniversalRouter bytecode size snapshot to 24,500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: allocate transient storage slots from a compact table

Move every transient storage slot the router uses (Locker, MaxInputAmount,
RouteSigner's three context slots, NestedUnlock) from keccak-derived
32-byte hashes to small literals allocated in a single TransientSlots
table. Each reference to a 32-byte constant costs a PUSH32 in the runtime
bytecode, so the hashed form spent roughly 700 bytes on nothing: transient
storage is private to this contract, so distinctness within the table is
the only requirement, and no inherited dependency touches transient
storage in the router's context.

Inline assembly only accepts literal constants, so each library keeps its
own literal; TransientSlotsTest pins every copy to the table and checks the
slots are distinct and do not alias at runtime.

Bytecode: 24,500 -> 23,804 (-696), leaving 772 bytes under the EIP-170
limit for the RESOLVE and protocol-fee commands in this release. Hardhat
gas snapshots regenerated: every changed entry fell by 76 to 288 gas
(optimizer layout at optimizer_runs = 1), no calldata sizes changed.

* feat: add RESOLVE command for execution-time amount resolution

Add the RESOLVE command (0x15): a bounded, read-only staticcall to a
caller-supplied IAmountResolver that stores the returned value in a
transient ResolvedAmount register. A later command consumes it by passing
the Constants.USE_RESOLVED_AMOUNT sentinel (1 << 127, distinct from
CONTRACT_BALANCE and OPEN_DELTA, and uint128-safe) in an amount field.
Wired into the v2/v3 swap amounts, TRANSFER, and every v4 swap action's
amountIn/amountOut through V4Router's _mapSwapAmount hook, which
V4SwapRouter overrides to substitute the register (SafeCast to uint128).
This lets a route obtain an amount only known onchain at execution time
(e.g. a live lending debt) instead of baking an offchain guess into
calldata.

- Register is transaction-scoped: it survives across commands and into
  EXECUTE_SUB_PLAN, and is cleared when the top-level execute returns so it
  never leaks into a later execute in the same transaction.
- Resolver is invoked via staticcall (no state mutation, no reentrancy) and
  at most one word of returndata is copied (return-bomb safe); a reverting
  or short-returning resolver yields success=false, composing with
  FLAG_ALLOW_REVERT.
- The register slot is allocated from the TransientSlots table.

Requires v4-periphery with the _mapSwapAmount hook (stacked on #593).

Bytecode: 23,804 -> 24,214 (+410), 362 bytes under the EIP-170 limit.

* chore: bump v4-periphery to abi.decode swap param decoders (#505)

* chore: bump v4-periphery to abi.decode swap param decoders

Points lib/v4-periphery at Uniswap/v4-periphery#593 (0cc6e164), which
replaces the assembly struct-pointer assignment in the four V4 swap
param decoders with abi.decode, so dynamic members are bounded by
params.length rather than calldatasize().

Costs 408 bytes of UniversalRouter runtime size at optimizer_runs=1,
leaving 76 bytes of headroom under the 24,576 limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: update gas snapshots for abi.decode swap decoders

Regenerated after the v4-periphery bump. All 66 changed entries are
gasUsed only; no calldataByteLength changed and no functional test
behavior changed.

Deltas run -168 to +480. The multi-swap UX benchmarks regress ~30 gas
per swap; most individual-command benchmarks improve ~30 gas. Both
directions are optimizer layout shifts at optimizer_runs = 1 rather
than a uniform per-swap cost -- the hardhat V4 coverage is migration
and mint, not V4 swaps, so the decoder's own swap cost is not
exercised here.

Also bumps the UniversalRouter bytecode size snapshot to 24,500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: drop the nested-unlock calldata re-encode, redundant under #505 (#513)

* refactor: drop the nested-unlock calldata re-encode, redundant under #505

The self-call `executeV4SwapWithinUnlock` existed to cap `calldatasize()` at
the input boundary, which mattered only because the old assembly swap-param
decoders followed struct and member offsets bounded by `calldatasize()`
rather than by `params.length`. The v4-periphery bump in #505 (now on this
branch) replaced those with `abi.decode`, which bounds the struct offset and
every dynamic member against the input length. The decoders end the read at
the input boundary on their own, on the nested path as much as the ordinary
one, so the re-encode is redundant.

The nested `V4_SWAP` branch decodes directly from its calldata slice again,
as it did before the opt-in commit.

Regression coverage moves with it: `V4NestedMemberSmuggle.t.sol` drives the
member-offset smuggle, where a redirected `path` member carries a whole
`PathKey` (hooks address included) sourced from bytes past
`inputs[0].length` -- the variant a struct-offset-only fix misses. It is
blocked. The existing struct-offset tests are unchanged and still pass, and
the obsolete `NotSelf` self-call test is dropped with the function.

The property is now guarded by the decoders plus these tests rather than by
the re-encode, so a future submodule bump that reintroduces unbounded
decoding fails loudly instead of silently.

Bytecode: 24,500 -> 24,353 runtime, leaving 223 bytes under EIP-170.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: update hardhat gas snapshots for the re-encode removal

Dropping `executeV4SwapWithinUnlock` removes one entry from the router's
external selector dispatch table, so every benchmark that enters the router
pays one fewer comparison. All 31 changed entries fall by exactly 22 gas, or
44 where a sub-plan enters twice. No `calldataByteLength` changed and no
benchmark regressed.

Values taken from the CI run on this branch rather than regenerated locally,
so they match the pinned Foundry 1.4.3 / solc toolchain CI uses instead of a
local 1.5.1 build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: allocate transient storage slots from a compact table

Move every transient storage slot the router uses (Locker, MaxInputAmount,
RouteSigner's three context slots, NestedUnlock) from keccak-derived
32-byte hashes to small literals allocated in a single TransientSlots
table. Each reference to a 32-byte constant costs a PUSH32 in the runtime
bytecode, so the hashed form spent roughly 700 bytes on nothing: transient
storage is private to this contract, so distinctness within the table is
the only requirement, and no inherited dependency touches transient
storage in the router's context.

Inline assembly only accepts literal constants, so each library keeps its
own literal; TransientSlotsTest pins every copy to the table and checks the
slots are distinct and do not alias at runtime.

Bytecode: 24,500 -> 23,804 (-696), leaving 772 bytes under the EIP-170
limit for the RESOLVE and protocol-fee commands in this release. Hardhat
gas snapshots regenerated: every changed entry fell by 76 to 288 gas
(optimizer layout at optimizer_runs = 1), no calldata sizes changed.

* test: rebaseline bytecode snapshot after rebase onto #513

Rebased onto fix/v4-nested-unlock-hardening, dropping the two commits that
#505's squash-merge already delivered to that branch. The new base also
carries #513, which removed the nested-unlock re-encode, so the baseline
moved from 24,500 to 24,353.

The slot table saves the same ~696 bytes against the new baseline:
24,353 -> 23,657, leaving 919 bytes under EIP-170.

Gas snapshots were reset to the base branch's values rather than merged
textually, since they are derived artifacts and the layout shifted again
under the new baseline. They are regenerated from this branch's CI run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: update hardhat gas snapshots for the slot table

Small literal slots replace keccak-derived ones, so each set/get/reset
pushes a 1-byte immediate instead of a 32-byte one. Every one of the 49
changed entries falls, by 128 to 288 gas. No calldataByteLength changed and
no benchmark regressed.

Values taken from this branch's CI run rather than regenerated locally, so
they match the Foundry 1.4.3 / solc toolchain CI pins instead of a local
1.5.1 build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: rebaseline bytecode snapshot after merging the base

The merge brings in #505's squash, #507's rebased slot table and #513's
re-encode removal. 24,067 runtime, 509 bytes under EIP-170.

Hardhat gas snapshots were taken from the base branch; they are regenerated
from this branch's CI run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: update hardhat gas snapshots after merging the base

The merge brings in #507's literal transient slots and #513's re-encode
removal, both of which shift layout at optimizer_runs = 1. All 66 changed
entries fall; no calldataByteLength changed and no benchmark regressed.

Values taken from this branch's CI run so they match the Foundry 1.4.3
toolchain CI pins rather than a local 1.5.1 build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: point v4-periphery at dev-ur-2.3.0

47227ba was the pre-merge head of the _mapSwapAmount branch. #593 and
#599 have since landed on v4-periphery's dev-ur-2.3.0 (ed72ce4), which
also carries #601-#604. The router's runtime bytecode is byte-for-byte
identical at both commits, so no snapshot changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(dispatcher): use the inherited _mapRecipient instead of a local map

BaseActionsRouter already maps MSG_SENDER and ADDRESS_THIS through the same
msgSender() override the router installs, so the Dispatcher copy was a second
body for the same function. Drop it, along with the ActionConstants import it
was the only user of. The via-IR optimizer had already merged the two
identical bodies, so runtime bytecode is unchanged at 24,067; this is a
source-level dedup only.

* refactor(resolve): share the sentinel mapping through ResolvedAmount.map

The Dispatcher helper and the V4SwapRouter override each compared an amount
against USE_RESOLVED_AMOUNT and read the register, so the same rule lived in
two places. Move it into the ResolvedAmount library as map(), which both call
sites use; the v4 override only narrows the result to uint128. This also
retires the helper named resolveAmount, which collided with the external
IAmountResolver.resolveAmount it calls. Runtime bytecode 24,067 to 24,051.

* feat(resolve): pick type(uint128).max as the USE_RESOLVED_AMOUNT sentinel

Any sentinel here must fit uint128: the v4 swap amounts are uint128 and the
ABI decoder rejects a wider word before the router sees it, which is the same
constraint that made OPEN_DELTA zero. Within that range 1 << 127 is a legal
amount under v4's total-supply assumption, while type(uint128).max is the top
of that range and the least plausible literal, so it is the value given up.
Nothing in the router, v4-periphery, or v4-core treats it as a sentinel today,
and the amountInMaximum and amountOutMinimum caps are not mapped, so they keep
their meaning.

* feat(resolve): fail RESOLVE on a zero result and revert on an empty register

A resolver that reverts, returns fewer than 32 bytes, or returns zero now
fails the RESOLVE command under the usual FLAG_ALLOW_REVERT semantics, and
every failure clears the register. Zero is not an amount any command can act
on: v2 and v3 reject it, TRANSFER moves nothing, and the v4 swap helpers read
it as OPEN_DELTA, which would silently swap whatever delta the plan left open.

With zero meaning empty, a command that consumes the sentinel on an empty
register reverts with ResolvedAmountUnset. That closes the sequence raised in
review, where a value left by an earlier RESOLVE was consumed after a later
RESOLVE failed, and the sentinel-before-RESOLVE case. A tolerated failure
therefore only composes with consumers inside an EXECUTE_SUB_PLAN that also
allows revert, which the new sub-plan test documents.

* test: update snapshots after the review changes

* chore: sync foundry.lock with the forge-std and v4-periphery pins

foundry.lock still recorded forge-std v1.5.5 and v4-periphery main @ 9dafaaec,
while the submodules pin forge-std v1.9.6 and v4-periphery dev-ur-2.3.0 @ ed72ce4.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: dianakocsis <diana.kocsis@uniswap.org>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: gate nested V4_SWAP behind an explicit opt-in entrypoint

#491 ran V4 actions inside a foreign PoolManager unlock whenever
poolManager.isUnlocked() was true, inferring consent from chain state. All V4
deltas accrue under the router's address for the lifetime of that unlock while
the router's own locker clears between external calls, so one call could leave
unpaid debt that a later call's SETTLE_ALL or OPEN_DELTA settlement paid from a
different caller's funds (audit finding M-01).

Consent now lives on the entrypoint. executeNested sets a transient flag for the
duration of the call and the nested branch requires it, so only a caller that
opened the surrounding unlock can reach the path. It cannot live in the payload:
EXECUTE_SIGNED_TYPEHASH commits to keccak256(commands), and a command byte would
let the route author, rather than the composer whose capital is at risk, make the
decision. Plain execute now reverts NestedExecutionNotPermitted instead.

An opted-in caller owns the delta hygiene the router can no longer guarantee on
its behalf; a permissionless function reaching this entrypoint can have its own
capital drained. This is accepted rather than enforced.

Signed routes cannot nest in this version, which is a regression against main
where isUnlocked() auto-detection allowed it. executeSignedNested would restore
it but costs 252 bytes against 473 remaining, and the RESOLVE proposal needs the
same headroom.

Also carries the calldata re-encode: the v4 parameter decoders follow struct and
member offsets bounded only by calldatasize(), so forwarding a slice of
transaction calldata let them read bytes the signature never covered.
executeV4SwapWithinUnlock re-enters through the ABI encoder so calldata ends
where the input ends. This removes reachability from the router's nested path
only -- the decoders themselves are unchanged and are fixed separately. It stays
load-bearing under the opt-in, since an attacker can opt themselves in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: bump v4-periphery to abi.decode swap param decoders

Points lib/v4-periphery at Uniswap/v4-periphery#593 (0cc6e164), which
replaces the assembly struct-pointer assignment in the four V4 swap
param decoders with abi.decode, so dynamic members are bounded by
params.length rather than calldatasize().

Costs 408 bytes of UniversalRouter runtime size at optimizer_runs=1,
leaving 76 bytes of headroom under the 24,576 limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: update gas snapshots for abi.decode swap decoders

Regenerated after the v4-periphery bump. All 66 changed entries are
gasUsed only; no calldataByteLength changed and no functional test
behavior changed.

Deltas run -168 to +480. The multi-swap UX benchmarks regress ~30 gas
per swap; most individual-command benchmarks improve ~30 gas. Both
directions are optimizer layout shifts at optimizer_runs = 1 rather
than a uniform per-swap cost -- the hardhat V4 coverage is migration
and mint, not V4 swaps, so the decoder's own swap cost is not
exercised here.

Also bumps the UniversalRouter bytecode size snapshot to 24,500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: allocate transient storage slots from a compact table

Move every transient storage slot the router uses (Locker, MaxInputAmount,
RouteSigner's three context slots, NestedUnlock) from keccak-derived
32-byte hashes to small literals allocated in a single TransientSlots
table. Each reference to a 32-byte constant costs a PUSH32 in the runtime
bytecode, so the hashed form spent roughly 700 bytes on nothing: transient
storage is private to this contract, so distinctness within the table is
the only requirement, and no inherited dependency touches transient
storage in the router's context.

Inline assembly only accepts literal constants, so each library keeps its
own literal; TransientSlotsTest pins every copy to the table and checks the
slots are distinct and do not alias at runtime.

Bytecode: 24,500 -> 23,804 (-696), leaving 772 bytes under the EIP-170
limit for the RESOLVE and protocol-fee commands in this release. Hardhat
gas snapshots regenerated: every changed entry fell by 76 to 288 gas
(optimizer layout at optimizer_runs = 1), no calldata sizes changed.

* feat: add RESOLVE command for execution-time amount resolution

Add the RESOLVE command (0x15): a bounded, read-only staticcall to a
caller-supplied IAmountResolver that stores the returned value in a
transient ResolvedAmount register. A later command consumes it by passing
the Constants.USE_RESOLVED_AMOUNT sentinel (1 << 127, distinct from
CONTRACT_BALANCE and OPEN_DELTA, and uint128-safe) in an amount field.
Wired into the v2/v3 swap amounts, TRANSFER, and every v4 swap action's
amountIn/amountOut through V4Router's _mapSwapAmount hook, which
V4SwapRouter overrides to substitute the register (SafeCast to uint128).
This lets a route obtain an amount only known onchain at execution time
(e.g. a live lending debt) instead of baking an offchain guess into
calldata.

- Register is transaction-scoped: it survives across commands and into
  EXECUTE_SUB_PLAN, and is cleared when the top-level execute returns so it
  never leaks into a later execute in the same transaction.
- Resolver is invoked via staticcall (no state mutation, no reentrancy) and
  at most one word of returndata is copied (return-bomb safe); a reverting
  or short-returning resolver yields success=false, composing with
  FLAG_ALLOW_REVERT.
- The register slot is allocated from the TransientSlots table.

Requires v4-periphery with the _mapSwapAmount hook (stacked on #593).

Bytecode: 23,804 -> 24,214 (+410), 362 bytes under the EIP-170 limit.

* feat: add V4_PROTOCOL_FEE_UPDATE command to poke the protocol fee controller

Newly initialized v4 pools carry no protocol fee until someone calls the
fee adapter that governance registered as the PoolManager's
protocolFeeController, so a keeper currently pokes new pools every ~15
minutes and misses the busiest window right after a launch.
V4_PROTOCOL_FEE_UPDATE (0x16) takes a PoolKey and calls triggerFeeUpdate
on the controller read from the PoolManager, so a swap route can push the
fee into pool state itself.

- The controller is discovered onchain; the router never holds an adapter
  address and follows a controller change automatically.
- Failure follows the other call-based commands: the poke's revert surfaces
  as ExecutionFailed unless FLAG_ALLOW_REVERT is set. With no controller
  registered the call targets address(0) and is a no-op, so one route shape
  works on every chain.
- v3 pools are not covered: a v3 branch measured another 185 bytes and does
  not fit alongside RESOLVE without further surgery.

Bytecode: 24,214 -> 24,390 (+176), 186 bytes under the EIP-170 limit.

* chore: bump v4-periphery to abi.decode swap param decoders (#505)

* chore: bump v4-periphery to abi.decode swap param decoders

Points lib/v4-periphery at Uniswap/v4-periphery#593 (0cc6e164), which
replaces the assembly struct-pointer assignment in the four V4 swap
param decoders with abi.decode, so dynamic members are bounded by
params.length rather than calldatasize().

Costs 408 bytes of UniversalRouter runtime size at optimizer_runs=1,
leaving 76 bytes of headroom under the 24,576 limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: update gas snapshots for abi.decode swap decoders

Regenerated after the v4-periphery bump. All 66 changed entries are
gasUsed only; no calldataByteLength changed and no functional test
behavior changed.

Deltas run -168 to +480. The multi-swap UX benchmarks regress ~30 gas
per swap; most individual-command benchmarks improve ~30 gas. Both
directions are optimizer layout shifts at optimizer_runs = 1 rather
than a uniform per-swap cost -- the hardhat V4 coverage is migration
and mint, not V4 swaps, so the decoder's own swap cost is not
exercised here.

Also bumps the UniversalRouter bytecode size snapshot to 24,500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: drop the nested-unlock calldata re-encode, redundant under #505 (#513)

* refactor: drop the nested-unlock calldata re-encode, redundant under #505

The self-call `executeV4SwapWithinUnlock` existed to cap `calldatasize()` at
the input boundary, which mattered only because the old assembly swap-param
decoders followed struct and member offsets bounded by `calldatasize()`
rather than by `params.length`. The v4-periphery bump in #505 (now on this
branch) replaced those with `abi.decode`, which bounds the struct offset and
every dynamic member against the input length. The decoders end the read at
the input boundary on their own, on the nested path as much as the ordinary
one, so the re-encode is redundant.

The nested `V4_SWAP` branch decodes directly from its calldata slice again,
as it did before the opt-in commit.

Regression coverage moves with it: `V4NestedMemberSmuggle.t.sol` drives the
member-offset smuggle, where a redirected `path` member carries a whole
`PathKey` (hooks address included) sourced from bytes past
`inputs[0].length` -- the variant a struct-offset-only fix misses. It is
blocked. The existing struct-offset tests are unchanged and still pass, and
the obsolete `NotSelf` self-call test is dropped with the function.

The property is now guarded by the decoders plus these tests rather than by
the re-encode, so a future submodule bump that reintroduces unbounded
decoding fails loudly instead of silently.

Bytecode: 24,500 -> 24,353 runtime, leaving 223 bytes under EIP-170.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: update hardhat gas snapshots for the re-encode removal

Dropping `executeV4SwapWithinUnlock` removes one entry from the router's
external selector dispatch table, so every benchmark that enters the router
pays one fewer comparison. All 31 changed entries fall by exactly 22 gas, or
44 where a sub-plan enters twice. No `calldataByteLength` changed and no
benchmark regressed.

Values taken from the CI run on this branch rather than regenerated locally,
so they match the pinned Foundry 1.4.3 / solc toolchain CI uses instead of a
local 1.5.1 build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: allocate transient storage slots from a compact table

Move every transient storage slot the router uses (Locker, MaxInputAmount,
RouteSigner's three context slots, NestedUnlock) from keccak-derived
32-byte hashes to small literals allocated in a single TransientSlots
table. Each reference to a 32-byte constant costs a PUSH32 in the runtime
bytecode, so the hashed form spent roughly 700 bytes on nothing: transient
storage is private to this contract, so distinctness within the table is
the only requirement, and no inherited dependency touches transient
storage in the router's context.

Inline assembly only accepts literal constants, so each library keeps its
own literal; TransientSlotsTest pins every copy to the table and checks the
slots are distinct and do not alias at runtime.

Bytecode: 24,500 -> 23,804 (-696), leaving 772 bytes under the EIP-170
limit for the RESOLVE and protocol-fee commands in this release. Hardhat
gas snapshots regenerated: every changed entry fell by 76 to 288 gas
(optimizer layout at optimizer_runs = 1), no calldata sizes changed.

* test: rebaseline bytecode snapshot after rebase onto #513

Rebased onto fix/v4-nested-unlock-hardening, dropping the two commits that
#505's squash-merge already delivered to that branch. The new base also
carries #513, which removed the nested-unlock re-encode, so the baseline
moved from 24,500 to 24,353.

The slot table saves the same ~696 bytes against the new baseline:
24,353 -> 23,657, leaving 919 bytes under EIP-170.

Gas snapshots were reset to the base branch's values rather than merged
textually, since they are derived artifacts and the layout shifted again
under the new baseline. They are regenerated from this branch's CI run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: update hardhat gas snapshots for the slot table

Small literal slots replace keccak-derived ones, so each set/get/reset
pushes a 1-byte immediate instead of a 32-byte one. Every one of the 49
changed entries falls, by 128 to 288 gas. No calldataByteLength changed and
no benchmark regressed.

Values taken from this branch's CI run rather than regenerated locally, so
they match the Foundry 1.4.3 / solc toolchain CI pins instead of a local
1.5.1 build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: add V3_PROTOCOL_FEE_UPDATE command to poke the v3 fee controller

Sibling to V4_PROTOCOL_FEE_UPDATE for Uniswap v3 pools. V3_PROTOCOL_FEE_UPDATE
(0x17) takes a pool address and calls triggerFeeUpdate(pool) on the fee adapter
that governance installed as the v3 factory owner, so a swap route can push a
newly created pool's protocol fee into its slot0 instead of waiting for the
offchain keeper.

- The adapter is discovered onchain as UNISWAP_V3_FACTORY.owner(); the router
  holds no adapter address and follows an ownership change automatically.
- Failure matches the other call-based commands: the poke's revert surfaces as
  ExecutionFailed unless FLAG_ALLOW_REVERT is set. With no adapter installed the
  call targets address(0) and is a no-op, so one route shape works everywhere.

This lands on top of dropping the nested-unlock re-encode, which freed the
bytecode it needs.

Bytecode: 24,243 -> 24,428 (+185), 148 bytes under the EIP-170 limit.

* test: rebaseline bytecode snapshot after merging the base

The merge brings in #505's squash, #507's rebased slot table and #513's
re-encode removal. 24,067 runtime, 509 bytes under EIP-170.

Hardhat gas snapshots were taken from the base branch; they are regenerated
from this branch's CI run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: rebaseline bytecode snapshot for both fee commands

Merging the base chain brings in #505's squash, #507's slot table and #513's
re-encode removal. With both the v4 and v3 poke commands: 24,428 runtime,
148 bytes under EIP-170.

Hardhat gas snapshots taken from the base; regenerated from this branch's CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: update hardhat gas snapshots after merging the base

The merge brings in #507's literal transient slots and #513's re-encode
removal, both of which shift layout at optimizer_runs = 1. All 66 changed
entries fall; no calldataByteLength changed and no benchmark regressed.

Values taken from this branch's CI run so they match the Foundry 1.4.3
toolchain CI pins rather than a local 1.5.1 build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: update hardhat gas snapshots for both fee commands

Snapshots were reset to the base branch's values during the merge, so this
restores them against this branch. The deltas are RESOLVE's per-amount-field
sentinel checks inherited from #508, plus two more comparisons in the
dispatch chain for the new command types; the poke commands themselves add
no cost to any existing benchmark.

All 66 changed entries, no calldataByteLength changed. Values taken from this
branch's CI run so they match the Foundry 1.4.3 toolchain CI pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct protocol fee update failure semantics; add v3 to planner

The README claimed one route shape works on every chain. The controller
read (PoolManager.protocolFeeController / v3 factory owner) is a
high-level call outside FLAG_ALLOW_REVERT, so on a chain without the
protocol deployed the command reverts the whole execute. Only a missing
adapter is a no-op.

Also add V3_PROTOCOL_FEE_UPDATE to the hardhat planner alongside v4.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test: fork-test protocol fee updates against the live adapters

The unit tests use mocks built from this PR's own IV3FeeAdapter and
IV4FeeAdapter, so an interface mismatch with the deployed adapters would
pass them. ProtocolFeeUpdate.fork.t.sol runs both commands on a mainnet
fork against the real PoolManager, v3 factory and fee adapters: a fresh
pool starts at protocol fee 0, the adapter resolves a nonzero fee for
it, and after the command the pool holds exactly that fee.

Also pin down the uncatchable failure the README documents: with no
PoolManager or v3 factory, the controller lookup reverts the whole
execute with empty data even under FLAG_ALLOW_REVERT.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test: don't pin revert data for the no-protocol fee update tests

forge 1.4.3 (CI) reports a high-level call to a no-code address as
"call to non-contract address" rather than a revert with empty data, so
expectRevert(bytes('')) failed there while passing on newer forge.

The data was never what the test needed: with FLAG_ALLOW_REVERT set, a
catchable failure skips the command and execute succeeds, so any revert
already proves the lookup failure is uncatchable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: point v4-periphery at dev-ur-2.3.0

47227ba was the pre-merge head of the _mapSwapAmount branch. #593 and
#599 have since landed on v4-periphery's dev-ur-2.3.0 (ed72ce4), which
also carries #601-#604. The router's runtime bytecode is byte-for-byte
identical at both commits, so no snapshot changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(dispatcher): use the inherited _mapRecipient instead of a local map

BaseActionsRouter already maps MSG_SENDER and ADDRESS_THIS through the same
msgSender() override the router installs, so the Dispatcher copy was a second
body for the same function. Drop it, along with the ActionConstants import it
was the only user of. The via-IR optimizer had already merged the two
identical bodies, so runtime bytecode is unchanged at 24,067; this is a
source-level dedup only.

* refactor(resolve): share the sentinel mapping through ResolvedAmount.map

The Dispatcher helper and the V4SwapRouter override each compared an amount
against USE_RESOLVED_AMOUNT and read the register, so the same rule lived in
two places. Move it into the ResolvedAmount library as map(), which both call
sites use; the v4 override only narrows the result to uint128. This also
retires the helper named resolveAmount, which collided with the external
IAmountResolver.resolveAmount it calls. Runtime bytecode 24,067 to 24,051.

* feat(resolve): pick type(uint128).max as the USE_RESOLVED_AMOUNT sentinel

Any sentinel here must fit uint128: the v4 swap amounts are uint128 and the
ABI decoder rejects a wider word before the router sees it, which is the same
constraint that made OPEN_DELTA zero. Within that range 1 << 127 is a legal
amount under v4's total-supply assumption, while type(uint128).max is the top
of that range and the least plausible literal, so it is the value given up.
Nothing in the router, v4-periphery, or v4-core treats it as a sentinel today,
and the amountInMaximum and amountOutMinimum caps are not mapped, so they keep
their meaning.

* feat(resolve): fail RESOLVE on a zero result and revert on an empty register

A resolver that reverts, returns fewer than 32 bytes, or returns zero now
fails the RESOLVE command under the usual FLAG_ALLOW_REVERT semantics, and
every failure clears the register. Zero is not an amount any command can act
on: v2 and v3 reject it, TRANSFER moves nothing, and the v4 swap helpers read
it as OPEN_DELTA, which would silently swap whatever delta the plan left open.

With zero meaning empty, a command that consumes the sentinel on an empty
register reverts with ResolvedAmountUnset. That closes the sequence raised in
review, where a value left by an earlier RESOLVE was consumed after a later
RESOLVE failed, and the sentinel-before-RESOLVE case. A tolerated failure
therefore only composes with consumers inside an EXECUTE_SUB_PLAN that also
allows revert, which the new sub-plan test documents.

* test: update snapshots after the review changes

* chore: sync foundry.lock with the forge-std and v4-periphery pins

foundry.lock still recorded forge-std v1.5.5 and v4-periphery main @ 9dafaaec,
while the submodules pin forge-std v1.9.6 and v4-periphery dev-ur-2.3.0 @ ed72ce4.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: dianakocsis <diana.kocsis@uniswap.org>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…523)

* fix(dispatcher): never let FLAG_ALLOW_REVERT swallow the nested-execution 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.

* docs(v2): correct the donation note in the exact-input path

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.

* docs: state that hook-funded exact-output routes leave pre-funded input 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.

* fix(bytes-lib): compare the head-word guard in whole words

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.

* feat: add a deadline-free executeNested overload

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.

* fix(v2): require exact-output routes to deliver the requested amount

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.

* docs: say which commands FLAG_ALLOW_REVERT actually affects

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.

* chore: point v4-periphery at the OZ 2.3.0 fix branch

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.

* test: rebaseline bytecode snapshot after the OZ 2.3.0 fixes

* test: update hardhat gas snapshots for the OZ 2.3.0 fixes

* docs(v2): don't claim skim(to) never reaches the donor

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>

* docs: make the exact-output sweep the general rule, not a hook special 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>

---------

Co-authored-by: dianakocsis <diana.kocsis@uniswap.org>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* docs: fix typo and stale max-command comment (OZ N-09)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor: call protocolFeeController on poolManager directly (OZ N-11)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor: make TransientSlots the single source of truth for slots (OZ N-10)

Declare the transient slots as file-level constants, which inline assembly
can reference when imported, and drop the per-library literal copies and
the tests that pinned them to the table. Bytecode is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: map Permit2 transfer amounts through the resolved-amount register (OZ L-03)

PERMIT2_TRANSFER_FROM and each PERMIT2_TRANSFER_FROM_BATCH detail now accept
the USE_RESOLVED_AMOUNT sentinel, so a route can pull exactly an amount a prior
RESOLVE produced. The batch transfers one detail at a time through Permit2's
single transferFrom, which runs the same per-detail transfer as its batch call
and avoids copying the details to memory. Document exactly which amount fields
read the sentinel.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: document that sub-plans share the resolved-amount register (OZ L-04)

The register is intentionally shared by a plan and its sub-plans: a RESOLVE in
a sub-plan that succeeds replaces the parent's value, and a sub-plan that
reverts has its writes undone, its RESOLVE included. Document this and correct
the _resolve comment, which implied a cleared value can never come back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: accept the resolved amount in every exact amount the router moves

Map the USE_RESOLVED_AMOUNT sentinel in WRAP_ETH, UNWRAP_WETH_EXACT, the
ACROSS_V4_DEPOSIT_V3 inputAmount, and the v4 SETTLE and TAKE amounts, so the
RESOLVE register covers every exact amount alongside the swap, TRANSFER and
Permit2 transfer amounts. Minimums, caps, thresholds, portions and signed permit
amounts keep their literal meaning.

The TAKE override needs _mapTakeAmount to be virtual, so point v4-periphery at
fix/oz-ur-2.3.0-audit-part2-findings (a4558a6) and update foundry.lock to match.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(v2): note that share-rounding tokens revert V2 exact-output swaps (OZ N-04)

V2 exact-output measures delivery at the recipient, so a token that hands over
less than the amount transferred anywhere on the path reverts the route. Add
share-based rounding such as stETH, which typically delivers 1-2 wei less, to
the documented causes, and point such tokens to exact-input swaps.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: add the OpenZeppelin UR 2.3.0 and v4-periphery diff audit report

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: add the OpenZeppelin UR SwapProxy and periphery audit report

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: pin v4-periphery to dev-ur-2.3.0 (7ed8439, the squash of #612)

#612 was squash-merged into dev-ur-2.3.0 and its branch deleted, which left the
previous pin (a4558a6) on no branch. 7ed8439 is the squash; its tree equals
#612's tip. The only difference from a4558a6 is the V4Quoter empty-path guard,
which is not part of the router, so the router bytecode is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

2 participants