Skip to content

fix: Permit2 allowance for PositionManager pulls + drop Rail-locker allowlist - #16

Merged
defidough merged 3 commits into
mainfrom
fix/rail-locker-not-launchpad-allowlisted
May 29, 2026
Merged

fix: Permit2 allowance for PositionManager pulls + drop Rail-locker allowlist#16
defidough merged 3 commits into
mainfrom
fix/rail-locker-not-launchpad-allowlisted

Conversation

@defidough

Copy link
Copy Markdown
Contributor

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)

Test Status Gas
`test_classic_mainline_launch_end_to_end` PASS ~4.5M
`test_setProtocolToken_locks_after_first_call` PASS ~4.5M
`test_rail_launch_opens_token_wedge_pool` PASS ~9.9M

Ran locally with `BASE_RPC_URL` set inline via shell environment — no RPC key persisted to disk.

Test plan

  • All 3 fork tests pass against real Base mainnet
  • Unit tests still green (155 total — added a couple from prior PRs)
  • `forge fmt --check` clean
  • CI green (unit tests; fork job runs on schedule)

defidough added 2 commits May 29, 2026 20:57
…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.
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
defidough merged commit 7b07502 into main May 29, 2026
2 checks passed
@defidough
defidough deleted the fix/rail-locker-not-launchpad-allowlisted branch May 29, 2026 20:31
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant