fix: Permit2 allowance for PositionManager pulls + drop Rail-locker allowlist - #16
Merged
Merged
Conversation
…llowlist Two real mainnet integration bugs caught by running the Phase 4 fork tests against live Base. Both pass now (Classic Mainline: 4.5M gas, Rail launch with WEDGE bootstrap: 9.9M gas). Bug 1 — Permit2 allowance pattern v4 PositionManager pulls ERC20s via Permit2 AllowanceTransfer, not via direct token allowance. The lockers were doing forceApprove(POSITION_MANAGER, supply), so SETTLE_PAIR reverted with AllowanceExpired(0). Fix in WedgeLpLocker.placeLiquidity and WedgeRailExtension.receiveTokens: approve Permit2 then call IAllowanceTransfer(PERMIT2).approve(token, PositionManager, amount, block.timestamp + 1 hours). PERMIT2 is the canonical 0x000000000022D473030F116dDEE9F6B43aC78BA3 deploy, same on every EVM chain. Bug 2 — Rail locker is not a Mainline locker WedgeRailLocker has no placeLiquidity and doesn't implement IWedgeLpLocker — its custody role is wired through the Rail extension (which safeTransferFroms LP NFTs to it). I wrongly allowlisted it via Launchpad.setLocker in both the deploy script (04_ConfigureAllowlists) and the fork setUp. The Launchpad's setLocker calls supportsInterface(IWedgeLpLocker) which the Rail locker doesn't implement — the deploy reverted on that staticcall. Removed the bad allowlist call in both places, with a comment explaining why. Test status: - Classic Mainline fork test: PASS against real Base PoolManager (0x4985...2b2b) + PositionManager (0x7C5f...9bDc). - Rail launch fork test: PASS — full WEDGE bootstrap, setProtocolToken, then a second-token Rail launch with the on-chain WEDGE/WETH spot read producing the right Rail starting tick. Both ran locally with BASE_RPC_URL set inline; nothing about the RPC key was persisted to disk.
5 tasks
Unit tests don't fork mainnet, so the canonical Permit2 address has no bytecode and the lockers' new IAllowanceTransfer(PERMIT2).approve calls reverted. Fix: etch a MockPermit2 (single no-op approve function) at the canonical address in the affected setUps. Real Permit2 semantics are exercised by the fork tests, which hit the live mainnet deploy at 0x0000...78BA3. 153/153 unit tests green.
defidough
added a commit
that referenced
this pull request
May 29, 2026
Fourth mainnet-fork suite. Verifies the complete fee economics
work against real Base v4:
test_hook_fee_sweeps_to_factory_on_next_swap:
snipe buy at t=0 → hook accumulates ERC-6909 internally,
factory still has 0 WETH. Second swap → hook's beforeSwap
sweeps the prior fee to factory. Asserts factory's WETH
balance went from 0 to > 0.
test_claimTeamFees_pushes_to_treasury:
two swaps to seed the factory with WETH fees. Owner calls
Launchpad.claimTeamFees(WETH). Asserts treasury receives
exactly the factory's full WETH balance and factory is
drained.
test_locker_collectFees_distributes_to_creator:
snipe + post-decay swap → LP positions accrue WETH fees.
Permissionless WedgeLpLocker.collectFees(token) called.
Asserts creator receives some WETH (LP-side). TOKEN balance
unchanged because all buys went WETH → TOKEN (no
TOKEN-side fee accrual yet).
Three tests, all PASS against real Base mainnet. Stacked on top
of the swap fork PR; rebases clean once #16 lands.
defidough
added a commit
that referenced
this pull request
May 29, 2026
* fix: Permit2 allowance for PositionManager pulls + drop Rail-locker allowlist
Two real mainnet integration bugs caught by running the Phase 4
fork tests against live Base. Both pass now (Classic Mainline:
4.5M gas, Rail launch with WEDGE bootstrap: 9.9M gas).
Bug 1 — Permit2 allowance pattern
v4 PositionManager pulls ERC20s via Permit2 AllowanceTransfer,
not via direct token allowance. The lockers were doing
forceApprove(POSITION_MANAGER, supply), so SETTLE_PAIR reverted
with AllowanceExpired(0). Fix in WedgeLpLocker.placeLiquidity
and WedgeRailExtension.receiveTokens: approve Permit2 then call
IAllowanceTransfer(PERMIT2).approve(token, PositionManager,
amount, block.timestamp + 1 hours). PERMIT2 is the canonical
0x000000000022D473030F116dDEE9F6B43aC78BA3 deploy, same on
every EVM chain.
Bug 2 — Rail locker is not a Mainline locker
WedgeRailLocker has no placeLiquidity and doesn't implement
IWedgeLpLocker — its custody role is wired through the Rail
extension (which safeTransferFroms LP NFTs to it). I wrongly
allowlisted it via Launchpad.setLocker in both the deploy
script (04_ConfigureAllowlists) and the fork setUp. The
Launchpad's setLocker calls supportsInterface(IWedgeLpLocker)
which the Rail locker doesn't implement — the deploy reverted
on that staticcall. Removed the bad allowlist call in both
places, with a comment explaining why.
Test status:
- Classic Mainline fork test: PASS against real Base PoolManager
(0x4985...2b2b) + PositionManager (0x7C5f...9bDc).
- Rail launch fork test: PASS — full WEDGE bootstrap, setProtocolToken,
then a second-token Rail launch with the on-chain WEDGE/WETH
spot read producing the right Rail starting tick.
Both ran locally with BASE_RPC_URL set inline; nothing about the
RPC key was persisted to disk.
* fmt: forge fmt fixes for Permit2 approve lines
* test(fork): swap end-to-end with MEV decay verification
Third mainnet-fork suite. Exercises a full swap against the real
v4 PoolManager + WedgeMainlineHook + locker, including the hook's
beforeSwap/afterSwap fee accounting (BeforeSwapDelta on specified
side, hook 6909 mint, sync/settle/take pattern).
TestSwapper helper:
- Custom IUnlockCallback that calls PoolManager.swap inside
unlock, then settles input via sync + transferFrom + settle
and takes output to recipient.
- Sidesteps UniversalRouter V4_SWAP plumbing (not in our pinned
UR version) while still hitting the full hook lifecycle.
- ~110 LOC, reusable across all swap-flavoured fork tests.
SwapMainnetFork suite (3 tests, all PASS against real Base):
- test_swap_at_launch_pays_mev_decay_fee: 0.01 WETH buy at t=0
succeeds; buyer's WETH fully consumed; got some TOKEN out.
- test_swap_after_decay_pays_base_fee: warp past 120s decay,
same 0.01 WETH buy succeeds at base 1.2% effective rate.
- test_swap_at_launch_vs_post_decay_buyer_gets_more_post_decay:
REAL regression assertion — same WETH input, post-decay buyer
gets strictly more TOKEN than the snipe buyer. Confirms MEV
decay works end-to-end.
Stacked on top of #16 (Permit2 fix) — that PR is merged in
locally for the test to run; will rebase clean once #16 lands.
153 unit tests + 6 fork tests across 3 suites.
* test: etch no-op Permit2 stub for unit tests
Unit tests don't fork mainnet, so the canonical Permit2 address
has no bytecode and the lockers' new
IAllowanceTransfer(PERMIT2).approve calls reverted. Fix: etch a
MockPermit2 (single no-op approve function) at the canonical
address in the affected setUps.
Real Permit2 semantics are exercised by the fork tests, which
hit the live mainnet deploy at 0x0000...78BA3.
153/153 unit tests green.
* test(fork): end-to-end fee round-trip — hook → factory → treasury + LP
Fourth mainnet-fork suite. Verifies the complete fee economics
work against real Base v4:
test_hook_fee_sweeps_to_factory_on_next_swap:
snipe buy at t=0 → hook accumulates ERC-6909 internally,
factory still has 0 WETH. Second swap → hook's beforeSwap
sweeps the prior fee to factory. Asserts factory's WETH
balance went from 0 to > 0.
test_claimTeamFees_pushes_to_treasury:
two swaps to seed the factory with WETH fees. Owner calls
Launchpad.claimTeamFees(WETH). Asserts treasury receives
exactly the factory's full WETH balance and factory is
drained.
test_locker_collectFees_distributes_to_creator:
snipe + post-decay swap → LP positions accrue WETH fees.
Permissionless WedgeLpLocker.collectFees(token) called.
Asserts creator receives some WETH (LP-side). TOKEN balance
unchanged because all buys went WETH → TOKEN (no
TOKEN-side fee accrual yet).
Three tests, all PASS against real Base mainnet. Stacked on top
of the swap fork PR; rebases clean once #16 lands.
* test(fork): sell-direction swap — exercises afterSwap delta path
The buy tests above hit beforeSwap's BeforeSwapDelta on the
specified side (cases 1 & 2 in WedgeMainlineHook). Sells hit
afterSwap's unspecifiedDelta path (cases 3 & 4). Without this
test, any amount0/1 flipping bug or wrong sign on the afterSwap
unspecifiedDelta computation would slip through.
Test:
1. Buy TOKEN post-decay so the seller has inventory.
2. Approve swapper for half the acquired TOKEN.
3. Sell that half back for WETH (zeroForOne flips with
tokenIsCurrency0).
4. Assert wethOut > 0.
PASSES against real Base mainnet. All 4 hook delta cases now
exercised end-to-end across the fork suite.
* test(fork): Rail swap + Rail fee → treasury
Fifth mainnet-fork suite. Verifies the full Wedge Rail mechanism:
test_rail_swap_wedge_for_token_succeeds:
Bootstrap WEDGE → launch TOKEN with Rail extension → deal
WEDGE to buyer → swap WEDGE→TOKEN through the hookless Rail
pool. Asserts buyer's WEDGE fully consumed, got TOKEN out.
test_rail_collectFees_routes_to_treasury:
Same setup + swap, then permissionless
WedgeRailLocker.collectFees(token). Asserts treasury's WEDGE
balance increased (100% of Rail fees → treasury per Phase 0.4),
no TOKEN balance change (only swapped WEDGE in).
Both pass against real Base mainnet at ~10M gas each (full
bootstrap + Rail seeding + swap + fee collect).
* test(fork): Rail sell direction (after buy seeds the pool)
The Rail launches single-sided with zero WEDGE on the WEDGE side
(launchpad seeds only TOKEN at the band tickLowers). A TOKEN→WEDGE
swap therefore can only succeed after at least one WEDGE→TOKEN
buy has populated the pool's WEDGE side.
Test pattern:
1. Buyer puts WEDGE in, gets TOKEN out (pool now has WEDGE).
2. Seller (fresh addr) puts TOKEN in, gets WEDGE out.
PASS against real Base. Confirms the Rail mechanism works both
ways once the pool is seeded — important for arb between Mainline
and Rail (arb flows in both directions over time).
* chore(WedgeLpLocker): mark onERC721Received pure
It only returns the selector constant — no state read or write.
Eliminates the persistent forge build warning that's been
spamming every CI run.
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.
Summary
Two real mainnet integration bugs surfaced by running the Phase 4 fork tests against live Base. Both fork tests pass after the fix.
Bug 1 — Permit2 allowance pattern
v4 `PositionManager` pulls ERC20s via Permit2's `AllowanceTransfer`, not via direct token allowance. The lockers were doing `forceApprove(POSITION_MANAGER, supply)`, so `SETTLE_PAIR` reverted with `AllowanceExpired(0)`.
Fix in `WedgeLpLocker.placeLiquidity` and `WedgeRailExtension.receiveTokens`:
```solidity
IERC20(token).forceApprove(PERMIT2, type(uint256).max);
IAllowanceTransfer(PERMIT2).approve(
token, address(POSITION_MANAGER), uint160(amount), uint48(block.timestamp + 1 hours)
);
```
`PERMIT2` is the canonical `0x000000000022D473030F116dDEE9F6B43aC78BA3` (same on every EVM chain).
Bug 2 — Rail locker is not a Mainline locker
`WedgeRailLocker` has no `placeLiquidity` and doesn't implement `IWedgeLpLocker` — its custody role is wired through the Rail extension (which `safeTransferFrom`s LP NFTs to it). I wrongly allowlisted it via `Launchpad.setLocker` in both the deploy script (`04_ConfigureAllowlists`) and the fork setUp. The Launchpad's `setLocker` calls `supportsInterface(IWedgeLpLocker)` which the Rail locker doesn't implement — the deploy reverted on that staticcall.
Removed the bad allowlist call in both places with a comment explaining why.
Fork test results (against real Base mainnet)
Ran locally with `BASE_RPC_URL` set inline via shell environment — no RPC key persisted to disk.
Test plan