Repository navigation
feat: Universal Router 2.3.0 release branch - #512
Open
dianakocsis wants to merge 10 commits into
Open
dianakocsis wants to merge 10 commits into
dianakocsis wants to merge 10 commits into
Conversation
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>
* 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>
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>
* 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>
* 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Important
Do not merge until the 2.3.0 audit clears and v4-periphery has landed on its
main(see Before merging tomain). 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
mainwas 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.V4_SWAP, gated behind an explicit opt-in (M-01). #491 letV4_SWAPrun inside a PoolManager unlock opened by another contract wheneverpoolManager.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 plainexecute/executeSignedrevert withNestedExecutionNotPermitted. For callers that opt in, the exposure is accepted, not enforced, andIUniversalRouterNatSpec documents both directions.checkInputLengthminimum-head checks on the inline-assembly decoders, a boundeddecodePermitBatch, aBytesLib.toLengthOffsetelement-size overload, and minimum-length checks inV3ToV4Migrator. Also addsUNWRAP_WETH_EXACT(0x0f) and widensSWEEP'samountMintouint256.V2SwapRouterandV3SwapRouterthatbalanceOf-derived trade sizes count donations, which can tripminHopPriceX36.TransientSlots, recovering bytecode.lib/v4-peripherya7af5b34→0cc6e164. Adds v4-periphery #593: swap param decoders useabi.decode, so they are bounded byparams.length, notcalldatasize(). Also keeps #584's hook-funded input tolerance, which v4-peripherymainreverted in #601 (0cc6e164forked before that revert).Queued into this branch
RESOLVEcommand for execution-time amounts. Also moveslib/v4-peripherytoed72ce4, thedev-ur-2.3.0tip. That adds v4-periphery #599's_mapSwapAmounthook and v4-peripherymain's permissioned-pool fixes #602 and #603. It also syncsfoundry.lockwith the submodule pins.Bytecode
UniversalRouterruntime, measured withforge build --sizes:main(543e1a1)5e74759)f157392)4ccc495)optimizer_runsis already1forUniversalRouter.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
mainBoth repos only squash-merge and delete the head branch, so the audited v4-periphery commit will not end up on v4-periphery's
main.dev-ur-2.3.0→main), with nothing else landing on itsmainfirst.lib/v4-peripheryandfoundry.lockto the squash commit on v4-peripherymain. Confirm its tree equals the audited commit's (git rev-parse <squash>^{tree}), then re-check bytecode and tests.When merging
mainforward 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
What's in it
mainwas 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.V4_SWAP, gated behind an explicit opt-in (M-01). #491 letV4_SWAPrun inside a PoolManager unlock opened by another contract wheneverpoolManager.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 plainexecute/executeSignedrevert withNestedExecutionNotPermitted. For callers that opt in, the exposure is accepted, not enforced, andIUniversalRouterNatSpec documents both directions.checkInputLengthminimum-head checks on the inline-assembly decoders, a boundeddecodePermitBatch, aBytesLib.toLengthOffsetelement-size overload, and minimum-length checks inV3ToV4Migrator. Also addsUNWRAP_WETH_EXACT(0x0f) and widensSWEEP'samountMintouint256.V2SwapRouterandV3SwapRouterthatbalanceOf-derived trade sizes count donations, which can tripminHopPriceX36.TransientSlots, recovering bytecode.lib/v4-peripherya7af5b34→0cc6e164. Adds v4-periphery #593: swap param decoders useabi.decode, so they are bounded byparams.length, notcalldatasize(). Also keeps #584's hook-funded input tolerance, which v4-peripherymainreverted in #601 (0cc6e164forked before that revert).Queued into this branch
RESOLVEcommand for execution-time amounts. Also moveslib/v4-peripherytoed72ce4, thedev-ur-2.3.0tip. That adds v4-periphery #599's_mapSwapAmounthook and v4-peripherymain's permissioned-pool fixes #602 and #603. It also syncsfoundry.lockwith the submodule pins.Bytecode
UniversalRouterruntime, measured withforge build --sizes:main(543e1a1)5e74759)f157392)4ccc495)optimizer_runsis already1forUniversalRouter.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
mainBoth repos only squash-merge and delete the head branch, so the audited v4-periphery commit will not end up on v4-periphery's
main.dev-ur-2.3.0→main), with nothing else landing on itsmainfirst.lib/v4-peripheryandfoundry.lockto the squash commit on v4-peripherymain. Confirm its tree equals the audited commit's (git rev-parse <squash>^{tree}), then re-check bytecode and tests.When merging
mainforward 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).