test(fork): swap end-to-end with MEV decay verification - #17
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.
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.
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.
…lowlisted' into test/swap-fork-coverage
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.
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.
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).
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).
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
Third mainnet-fork suite — actual swaps against real v4 Base mainnet. Exercises the hook's `beforeSwap` / `afterSwap` fee accounting end-to-end including the `BeforeSwapDelta` on specified side, hook 6909 mint, and `sync` / `settle` / `take` reconciliation.
TestSwapper helper
SwapMainnetFork suite (3 tests, all PASS against real Base)
All three pass locally against real Base mainnet at ~4.7M gas per launch + swap.
Stacking
This branches off main with the #16 fix merged in locally so the test runs. Will rebase cleanly once #16 lands.
Test plan