|
| 1 | +// SPDX-License-Identifier: MIT |
| 2 | +pragma solidity ^0.8.26; |
| 3 | + |
| 4 | +import {console} from "forge-std/src/console.sol"; |
| 5 | +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; |
| 6 | +import {ForexSwap} from "../src/ForexSwap.sol"; |
| 7 | +import {BaseScript} from "./Base.s.sol"; |
| 8 | +import {BaseCustomAccounting} from "uniswap-hooks/src/base/BaseCustomAccounting.sol"; |
| 9 | +import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; |
| 10 | +import {IHooks} from "v4-core/src/interfaces/IHooks.sol"; |
| 11 | +import {PoolKey} from "v4-core/src/types/PoolKey.sol"; |
| 12 | +import {Currency} from "v4-core/src/types/Currency.sol"; |
| 13 | +import {LPFeeLibrary} from "v4-core/src/libraries/LPFeeLibrary.sol"; |
| 14 | +import {Hooks} from "v4-core/src/libraries/Hooks.sol"; |
| 15 | +import {TickMath} from "v4-core/src/libraries/TickMath.sol"; |
| 16 | +import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; |
| 17 | + |
| 18 | +contract Create2FactorySepolia { |
| 19 | + error DeploymentFailed(); |
| 20 | + |
| 21 | + function deploy(bytes32 salt, bytes memory creationCode) external returns (address deployed) { |
| 22 | + assembly ("memory-safe") { |
| 23 | + deployed := create2(0, add(creationCode, 0x20), mload(creationCode), salt) |
| 24 | + } |
| 25 | + |
| 26 | + if (deployed == address(0)) revert DeploymentFailed(); |
| 27 | + } |
| 28 | +} |
| 29 | + |
| 30 | +contract BaseSepoliaE2E is BaseScript { |
| 31 | + struct RunState { |
| 32 | + IPoolManager poolManager; |
| 33 | + PoolSwapTest swapRouter; |
| 34 | + MockERC20 tokenA; |
| 35 | + MockERC20 tokenB; |
| 36 | + ForexSwap hook; |
| 37 | + Create2FactorySepolia factory; |
| 38 | + PoolKey key; |
| 39 | + bytes32 salt; |
| 40 | + address predictedHook; |
| 41 | + } |
| 42 | + |
| 43 | + address internal constant BASE_SEPOLIA_POOL_MANAGER = 0x05E73354cFDd6745C338b50BcFDfA3Aa6fA03408; |
| 44 | + address internal constant BASE_SEPOLIA_POOL_SWAP_TEST = 0x8B5bcC363ddE2614281aD875bad385E0A785D3B9; |
| 45 | + uint160 internal constant REQUIRED_HOOK_FLAGS = Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_ADD_LIQUIDITY_FLAG |
| 46 | + | Hooks.BEFORE_REMOVE_LIQUIDITY_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG; |
| 47 | + uint160 internal constant SQRT_PRICE_1_1 = 79228162514264337593543950336; |
| 48 | + uint256 internal constant TOKEN_SUPPLY = 1_000_000e18; |
| 49 | + uint256 internal constant ADD_AMOUNT0_DESIRED = 100e18; |
| 50 | + uint256 internal constant ADD_AMOUNT1_DESIRED = 250e18; |
| 51 | + uint256 internal constant SWAP_AMOUNT_IN = 1e18; |
| 52 | + int24 internal constant TICK_SPACING = 60; |
| 53 | + int24 internal constant TICK_LOWER = -120; |
| 54 | + int24 internal constant TICK_UPPER = 120; |
| 55 | + |
| 56 | + function run() public broadcast returns (ForexSwap hook) { |
| 57 | + RunState memory s; |
| 58 | + s.poolManager = IPoolManager(BASE_SEPOLIA_POOL_MANAGER); |
| 59 | + s.swapRouter = PoolSwapTest(BASE_SEPOLIA_POOL_SWAP_TEST); |
| 60 | + s.tokenA = new MockERC20("ForexSwap Base Sepolia A", "FXA", 18); |
| 61 | + s.tokenB = new MockERC20("ForexSwap Base Sepolia B", "FXB", 18); |
| 62 | + s.tokenA.mint(broadcaster, TOKEN_SUPPLY); |
| 63 | + s.tokenB.mint(broadcaster, TOKEN_SUPPLY); |
| 64 | + |
| 65 | + (Currency currency0, Currency currency1) = _sortCurrencies(address(s.tokenA), address(s.tokenB)); |
| 66 | + (s.hook, s.factory, s.salt, s.predictedHook) = _deployHook(s.poolManager); |
| 67 | + |
| 68 | + s.key = PoolKey({ |
| 69 | + currency0: currency0, |
| 70 | + currency1: currency1, |
| 71 | + fee: LPFeeLibrary.DYNAMIC_FEE_FLAG, |
| 72 | + tickSpacing: TICK_SPACING, |
| 73 | + hooks: IHooks(address(s.hook)) |
| 74 | + }); |
| 75 | + |
| 76 | + s.poolManager.initialize(s.key, SQRT_PRICE_1_1); |
| 77 | + uint256 deadline = block.timestamp + 1 hours; |
| 78 | + (uint256 amount0, uint256 amount1, uint256 shares) = _bootstrapLiquidity(s.hook, s.tokenA, s.tokenB, deadline); |
| 79 | + uint256 quotedOut = _executeSwap(s.hook, s.swapRouter, s.key, currency1, s.tokenA, s.tokenB); |
| 80 | + _removeHalfLiquidity(s.hook, shares, deadline); |
| 81 | + |
| 82 | + (uint256 reserve0, uint256 reserve1, uint256 liquidityL, uint256 priceWad, bool paused_) = s.hook.getPoolInfo(); |
| 83 | + require(!paused_, "hook paused"); |
| 84 | + require(reserve0 > 0 && reserve1 > 0 && liquidityL > 0 && priceWad > 0, "post-state invalid"); |
| 85 | + |
| 86 | + console.log("PoolManager:", address(s.poolManager)); |
| 87 | + console.log("PoolSwapTest:", address(s.swapRouter)); |
| 88 | + console.log("TokenA:", address(s.tokenA)); |
| 89 | + console.log("TokenB:", address(s.tokenB)); |
| 90 | + console.log("CREATE2 factory:", address(s.factory)); |
| 91 | + console.log("Hook salt:", uint256(s.salt)); |
| 92 | + console.log("Predicted hook:", s.predictedHook); |
| 93 | + console.log("Deployed hook:", address(s.hook)); |
| 94 | + console.log("Bootstrap amount0:", amount0); |
| 95 | + console.log("Bootstrap amount1:", amount1); |
| 96 | + console.log("Liquidity shares:", shares); |
| 97 | + console.log("Quoted swap output:", quotedOut); |
| 98 | + console.log("Final reserve0:", reserve0); |
| 99 | + console.log("Final reserve1:", reserve1); |
| 100 | + console.log("Final liquidity:", liquidityL); |
| 101 | + console.log("Final priceWad:", priceWad); |
| 102 | + hook = s.hook; |
| 103 | + } |
| 104 | + |
| 105 | + function _deployHook(IPoolManager poolManager) |
| 106 | + internal |
| 107 | + returns (ForexSwap hook, Create2FactorySepolia factory, bytes32 salt, address predictedHook) |
| 108 | + { |
| 109 | + factory = new Create2FactorySepolia(); |
| 110 | + bytes memory creationCode = abi.encodePacked(type(ForexSwap).creationCode, abi.encode(poolManager)); |
| 111 | + bytes32 initCodeHash = keccak256(creationCode); |
| 112 | + (salt, predictedHook) = _mineHookSalt(address(factory), initCodeHash); |
| 113 | + hook = ForexSwap(factory.deploy(salt, creationCode)); |
| 114 | + } |
| 115 | + |
| 116 | + function _bootstrapLiquidity(ForexSwap hook, MockERC20 tokenA, MockERC20 tokenB, uint256 deadline) |
| 117 | + internal |
| 118 | + returns (uint256 amount0, uint256 amount1, uint256 shares) |
| 119 | + { |
| 120 | + tokenA.approve(address(hook), type(uint256).max); |
| 121 | + tokenB.approve(address(hook), type(uint256).max); |
| 122 | + |
| 123 | + (amount0, amount1, shares) = |
| 124 | + hook.addLiquidityWithSlippage(ADD_AMOUNT0_DESIRED, ADD_AMOUNT1_DESIRED, 0, 0, deadline); |
| 125 | + require(amount0 > 0 && amount1 > 0 && shares > 0, "bootstrap quote failed"); |
| 126 | + |
| 127 | + hook.addLiquidity( |
| 128 | + BaseCustomAccounting.AddLiquidityParams({ |
| 129 | + amount0Desired: ADD_AMOUNT0_DESIRED, |
| 130 | + amount1Desired: ADD_AMOUNT1_DESIRED, |
| 131 | + amount0Min: 0, |
| 132 | + amount1Min: 0, |
| 133 | + to: broadcaster, |
| 134 | + deadline: deadline, |
| 135 | + tickLower: TICK_LOWER, |
| 136 | + tickUpper: TICK_UPPER, |
| 137 | + salt: ZERO_SALT |
| 138 | + }) |
| 139 | + ); |
| 140 | + |
| 141 | + require(hook.balanceOf(broadcaster) == shares, "liquidity shares mismatch"); |
| 142 | + } |
| 143 | + |
| 144 | + function _executeSwap( |
| 145 | + ForexSwap hook, |
| 146 | + PoolSwapTest swapRouter, |
| 147 | + PoolKey memory key, |
| 148 | + Currency currency1, |
| 149 | + MockERC20 tokenA, |
| 150 | + MockERC20 tokenB |
| 151 | + ) internal returns (uint256 quotedOut) { |
| 152 | + tokenA.approve(address(swapRouter), type(uint256).max); |
| 153 | + tokenB.approve(address(swapRouter), type(uint256).max); |
| 154 | + |
| 155 | + quotedOut = hook.calculateAmountOut(SWAP_AMOUNT_IN, true); |
| 156 | + require(quotedOut > 0, "swap quote failed"); |
| 157 | + |
| 158 | + uint256 token1BalanceBefore = MockERC20(Currency.unwrap(currency1)).balanceOf(broadcaster); |
| 159 | + swapRouter.swap( |
| 160 | + key, |
| 161 | + IPoolManager.SwapParams({ |
| 162 | + zeroForOne: true, |
| 163 | + amountSpecified: -int256(SWAP_AMOUNT_IN), |
| 164 | + sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 |
| 165 | + }), |
| 166 | + PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), |
| 167 | + "" |
| 168 | + ); |
| 169 | + uint256 token1BalanceAfter = MockERC20(Currency.unwrap(currency1)).balanceOf(broadcaster); |
| 170 | + require(token1BalanceAfter > token1BalanceBefore, "swap output missing"); |
| 171 | + } |
| 172 | + |
| 173 | + function _removeHalfLiquidity(ForexSwap hook, uint256 shares, uint256 deadline) internal { |
| 174 | + uint256 sharesToRemove = shares / 2; |
| 175 | + require(sharesToRemove > 0, "remove amount too small"); |
| 176 | + hook.removeLiquidity( |
| 177 | + BaseCustomAccounting.RemoveLiquidityParams({ |
| 178 | + liquidity: sharesToRemove, |
| 179 | + amount0Min: 0, |
| 180 | + amount1Min: 0, |
| 181 | + deadline: deadline, |
| 182 | + tickLower: TICK_LOWER, |
| 183 | + tickUpper: TICK_UPPER, |
| 184 | + salt: ZERO_SALT |
| 185 | + }) |
| 186 | + ); |
| 187 | + } |
| 188 | + |
| 189 | + function _sortCurrencies(address tokenA, address tokenB) internal pure returns (Currency currency0, Currency currency1) { |
| 190 | + currency0 = Currency.wrap(tokenA); |
| 191 | + currency1 = Currency.wrap(tokenB); |
| 192 | + if (Currency.unwrap(currency0) > Currency.unwrap(currency1)) { |
| 193 | + (currency0, currency1) = (currency1, currency0); |
| 194 | + } |
| 195 | + } |
| 196 | + |
| 197 | + function _mineHookSalt(address deployer, bytes32 initCodeHash) internal view returns (bytes32 salt, address hook) { |
| 198 | + for (uint256 candidate = 0; candidate < type(uint24).max; candidate++) { |
| 199 | + salt = bytes32(candidate); |
| 200 | + hook = vm.computeCreate2Address(salt, initCodeHash, deployer); |
| 201 | + if (uint160(hook) & Hooks.ALL_HOOK_MASK == REQUIRED_HOOK_FLAGS) { |
| 202 | + return (salt, hook); |
| 203 | + } |
| 204 | + } |
| 205 | + |
| 206 | + revert("No hook salt found"); |
| 207 | + } |
| 208 | +} |
0 commit comments