From 59804104858392c3fa712f22205ee118f90df11a Mon Sep 17 00:00:00 2001 From: david-uniswap <274080779+david-uniswap@users.noreply.github.com> Date: Fri, 22 May 2026 14:17:13 -0700 Subject: [PATCH 1/4] add chain-agnostic smoke tests for v2, v3, v4 (with UR swap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Foundry scripts under script/smoke/ that exercise the full LP+swap flow against deployed contracts. They read addresses from the chain's deployments/json/.json so they work on any chain that has the expected contracts recorded — not specific to Robinhood. What each test does: - V2SmokeTest: wraps ETH, deploys a fresh test ERC-20, addLiquidity via Router02 to create a new pair, swaps test token -> WETH. - V3SmokeTest: same setup, creates+initializes a 0.3% pool via Factory, mints a position via NonfungiblePositionManager, swaps via SwapRouter02. - V4SmokeTest: same setup, initializes a pool via PositionManager, mints a position through the Permit2 + modifyLiquidities flow, then swaps test token -> WETH via UniversalRouter's V4_SWAP command (validates Permit2 wiring, PoolManager unlock pattern, V4Router decoder, and UR command dispatch all end-to-end). WETH address is derived from a deployed contract (v2 Router02.WETH() for v2 test, v3 NPM.WETH9() for v3 and v4 tests) so no external chain-specific config is needed. foundry.toml: added "deployments/" to fs_permissions so the tests can read the deployments registry. .gitignore: excluded broadcast/V*SmokeTest.s.sol/** — smoke test broadcast logs are transient validation artifacts, not deployment records. Validated on Robinhood Chain (4663): - V2: total 0.000352 ETH, swap landed - V3: total 0.000618 ETH, swap landed - V4: total 0.000159 ETH, swap via UR landed (1 TEST -> ~5e13 wei WETH) Co-Authored-By: Claude Opus 4.7 --- .gitignore | 4 + foundry.toml | 3 +- script/smoke/V2SmokeTest.s.sol | 140 +++++++++++++++++ script/smoke/V3SmokeTest.s.sol | 195 ++++++++++++++++++++++++ script/smoke/V4SmokeTest.s.sol | 264 +++++++++++++++++++++++++++++++++ 5 files changed, 605 insertions(+), 1 deletion(-) create mode 100644 script/smoke/V2SmokeTest.s.sol create mode 100644 script/smoke/V3SmokeTest.s.sol create mode 100644 script/smoke/V4SmokeTest.s.sol diff --git a/.gitignore b/.gitignore index 964ba404..429e58be 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,10 @@ deployments/**/31337.* broadcast/**/dry-run/** script/deploy/tasks/1337/** script/deploy/tasks/31337/** +# Smoke test broadcast logs are transient validation artifacts, not deployment records +broadcast/V2SmokeTest.s.sol/** +broadcast/V3SmokeTest.s.sol/** +broadcast/V4SmokeTest.s.sol/** debug/ Cargo.lock diff --git a/foundry.toml b/foundry.toml index 8f8bbe7d..5e4c6843 100644 --- a/foundry.toml +++ b/foundry.toml @@ -11,7 +11,8 @@ fs_permissions = [ { access = "read-write", path = ".forge-snapshots"}, { access = "read", path = "script/" }, { access = "read-write", path = "script/deploy/tasks" }, - { access = "read", path = "out/" } + { access = "read", path = "out/" }, + { access = "read", path = "deployments/" } ] skip = [ diff --git a/script/smoke/V2SmokeTest.s.sol b/script/smoke/V2SmokeTest.s.sol new file mode 100644 index 00000000..9da487e2 --- /dev/null +++ b/script/smoke/V2SmokeTest.s.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2 as console} from 'forge-std/Script.sol'; + +interface IERC20Min { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); +} + +interface IWETH9 is IERC20Min { + function deposit() external payable; +} + +interface IV2Factory { + function getPair(address tokenA, address tokenB) external view returns (address); +} + +interface IV2Router02 { + function WETH() external view returns (address); + + function addLiquidity( + address tokenA, + address tokenB, + uint256 amountADesired, + uint256 amountBDesired, + uint256 amountAMin, + uint256 amountBMin, + address to, + uint256 deadline + ) external returns (uint256 amountA, uint256 amountB, uint256 liquidity); + + function swapExactTokensForTokens( + uint256 amountIn, + uint256 amountOutMin, + address[] calldata path, + address to, + uint256 deadline + ) external returns (uint256[] memory amounts); +} + +contract TestToken { + string public name = 'SmokeV2 Token'; + string public symbol = 'SMK2'; + uint8 public decimals = 18; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + constructor(uint256 _supply) { + totalSupply = _supply; + balanceOf[msg.sender] = _supply; + } + + function approve(address s, uint256 a) external returns (bool) { + allowance[msg.sender][s] = a; + return true; + } + + function transfer(address to, uint256 a) external returns (bool) { + balanceOf[msg.sender] -= a; + balanceOf[to] += a; + return true; + } + + function transferFrom(address from, address to, uint256 a) external returns (bool) { + if (allowance[from][msg.sender] != type(uint256).max) allowance[from][msg.sender] -= a; + balanceOf[from] -= a; + balanceOf[to] += a; + return true; + } +} + +contract V2SmokeTest is Script { + function run() public { + // Chain-agnostic address resolution + string memory chainIdStr = vm.toString(block.chainid); + string memory path = string.concat('./deployments/json/', chainIdStr, '.json'); + string memory json = vm.readFile(path); + + address v2Factory = vm.parseJsonAddress(json, '.latest.UniswapV2Factory.address'); + address v2Router = vm.parseJsonAddress(json, '.latest.UniswapV2Router02.address'); + // Derive WETH from the deployed Router02 — works on any chain + address weth = IV2Router02(v2Router).WETH(); + + require(v2Factory != address(0), 'UniswapV2Factory not in deployments JSON'); + require(v2Router != address(0), 'UniswapV2Router02 not in deployments JSON'); + require(weth != address(0), 'WETH not derivable from Router'); + + vm.startBroadcast(); + address me = msg.sender; + console.log('Chain:', block.chainid); + console.log('Deployer:', me); + console.log('V2 Factory:', v2Factory); + console.log('V2 Router02:', v2Router); + console.log('WETH:', weth); + + IWETH9(weth).deposit{value: 0.0001 ether}(); + console.log('Wrapped 0.0001 ETH; WETH balance:', IERC20Min(weth).balanceOf(me)); + + TestToken test = new TestToken(1_000_000 ether); + console.log('Test token:', address(test)); + + IWETH9(weth).approve(v2Router, type(uint256).max); + test.approve(v2Router, type(uint256).max); + + (uint256 amtA, uint256 amtB, uint256 liq) = IV2Router02(v2Router) + .addLiquidity(weth, address(test), 0.000_05 ether, 1000 ether, 0, 0, me, block.timestamp + 3600); + console.log('Added v2 liquidity:'); + console.log(' amountA (WETH):', amtA); + console.log(' amountB (TEST):', amtB); + console.log(' LP tokens:', liq); + require(liq > 0, 'no LP minted'); + + address pair = IV2Factory(v2Factory).getPair(weth, address(test)); + console.log('Pair address:', pair); + require(pair != address(0), 'pair address should be non-zero'); + + uint256 wethBefore = IERC20Min(weth).balanceOf(me); + address[] memory swapPath = new address[](2); + swapPath[0] = address(test); + swapPath[1] = weth; + + uint256[] memory amounts = + IV2Router02(v2Router).swapExactTokensForTokens(1 ether, 0, swapPath, me, block.timestamp + 3600); + uint256 wethAfter = IERC20Min(weth).balanceOf(me); + + console.log('Swap: 1 TEST -> WETH'); + console.log(' amount in:', amounts[0]); + console.log(' amount out:', amounts[1]); + console.log(' WETH delta:', wethAfter - wethBefore); + require(amounts[1] > 0, 'swap returned zero'); + require(wethAfter > wethBefore, "WETH balance didn't increase"); + + console.log(''); + console.log('SUCCESS: v2 pair created, liquidity added, swap completed'); + vm.stopBroadcast(); + } +} diff --git a/script/smoke/V3SmokeTest.s.sol b/script/smoke/V3SmokeTest.s.sol new file mode 100644 index 00000000..abc59bba --- /dev/null +++ b/script/smoke/V3SmokeTest.s.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2 as console} from 'forge-std/Script.sol'; + +interface IERC20Min { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address) external view returns (uint256); +} + +interface IWETH9 is IERC20Min { + function deposit() external payable; +} + +interface IV3Factory { + function createPool(address tokenA, address tokenB, uint24 fee) external returns (address); + function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address); +} + +interface IUniswapV3Pool { + function initialize(uint160 sqrtPriceX96) external; + function slot0() external view returns (uint160, int24, uint16, uint16, uint16, uint8, bool); +} + +interface INonfungiblePositionManager { + function WETH9() external view returns (address); + + struct MintParams { + address token0; + address token1; + uint24 fee; + int24 tickLower; + int24 tickUpper; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + address recipient; + uint256 deadline; + } + function mint(MintParams calldata) external payable returns (uint256, uint128, uint256, uint256); +} + +interface ISwapRouter02 { + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + function exactInputSingle(ExactInputSingleParams calldata) external payable returns (uint256); +} + +contract TestToken { + string public name = 'SmokeV3 Token'; + string public symbol = 'SMK3'; + uint8 public decimals = 18; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + constructor(uint256 _s) { + totalSupply = _s; + balanceOf[msg.sender] = _s; + } + + function approve(address s, uint256 a) external returns (bool) { + allowance[msg.sender][s] = a; + return true; + } + + function transfer(address to, uint256 a) external returns (bool) { + balanceOf[msg.sender] -= a; + balanceOf[to] += a; + return true; + } + + function transferFrom(address f, address t, uint256 a) external returns (bool) { + if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= a; + balanceOf[f] -= a; + balanceOf[t] += a; + return true; + } +} + +contract V3SmokeTest is Script { + uint24 constant FEE = 3000; + int24 constant TICK_LOWER = -887_220; + int24 constant TICK_UPPER = 887_220; + uint160 constant SQRT_PRICE_1_1 = 79_228_162_514_264_337_593_543_950_336; + + function run() public { + string memory chainIdStr = vm.toString(block.chainid); + string memory path = string.concat('./deployments/json/', chainIdStr, '.json'); + string memory json = vm.readFile(path); + + address v3Factory = vm.parseJsonAddress(json, '.latest.UniswapV3Factory.address'); + address npm = vm.parseJsonAddress(json, '.latest.NonfungiblePositionManager.address'); + address swapRouter = vm.parseJsonAddress(json, '.latest.SwapRouter02.address'); + address weth = INonfungiblePositionManager(npm).WETH9(); + + require(v3Factory != address(0), 'UniswapV3Factory not in deployments JSON'); + require(npm != address(0), 'NonfungiblePositionManager not in deployments JSON'); + require(swapRouter != address(0), 'SwapRouter02 not in deployments JSON'); + + vm.startBroadcast(); + address me = msg.sender; + console.log('Chain:', block.chainid); + console.log('Deployer:', me); + console.log('V3 Factory:', v3Factory); + console.log('NPM:', npm); + console.log('SwapRouter02:', swapRouter); + console.log('WETH:', weth); + + IWETH9(weth).deposit{value: 0.0001 ether}(); + TestToken test = new TestToken(1_000_000 ether); + console.log('Test token:', address(test)); + + address pool = _createPool(v3Factory, address(test), weth); + _mintPosition(npm, address(test), weth, me); + _doSwap(swapRouter, address(test), weth, me); + pool; // silence unused warning + + console.log(''); + console.log('SUCCESS: v3 pool created, position minted, swap completed'); + vm.stopBroadcast(); + } + + function _createPool(address v3Factory, address test, address weth) internal returns (address pool) { + (address t0, address t1) = weth < test ? (weth, test) : (test, weth); + pool = IV3Factory(v3Factory).createPool(t0, t1, FEE); + IUniswapV3Pool(pool).initialize(SQRT_PRICE_1_1); + (uint160 sqrtPx,,,,,,) = IUniswapV3Pool(pool).slot0(); + console.log('Pool:', pool); + console.log('Pool sqrtPriceX96:', sqrtPx); + } + + function _mintPosition(address npm, address test, address weth, address me) internal { + IWETH9(weth).approve(npm, type(uint256).max); + TestToken(test).approve(npm, type(uint256).max); + + (address t0, address t1) = weth < test ? (weth, test) : (test, weth); + uint256 amt0 = t0 == weth ? uint256(0.000_05 ether) : uint256(1000 ether); + uint256 amt1 = t1 == weth ? uint256(0.000_05 ether) : uint256(1000 ether); + + INonfungiblePositionManager.MintParams memory mp = INonfungiblePositionManager.MintParams({ + token0: t0, + token1: t1, + fee: FEE, + tickLower: TICK_LOWER, + tickUpper: TICK_UPPER, + amount0Desired: amt0, + amount1Desired: amt1, + amount0Min: 0, + amount1Min: 0, + recipient: me, + deadline: block.timestamp + 3600 + }); + + (uint256 tokenId, uint128 liq, uint256 a0, uint256 a1) = INonfungiblePositionManager(npm).mint(mp); + console.log('Minted v3 position:'); + console.log(' tokenId:', tokenId); + console.log(' liquidity:', liq); + console.log(' amount0:', a0); + console.log(' amount1:', a1); + require(liq > 0, 'no liquidity minted'); + } + + function _doSwap(address swapRouter, address test, address weth, address me) internal { + TestToken(test).approve(swapRouter, type(uint256).max); + uint256 wethBefore = IERC20Min(weth).balanceOf(me); + + ISwapRouter02.ExactInputSingleParams memory sp = ISwapRouter02.ExactInputSingleParams({ + tokenIn: test, + tokenOut: weth, + fee: FEE, + recipient: me, + amountIn: 1 ether, + amountOutMinimum: 0, + sqrtPriceLimitX96: 0 + }); + + uint256 out = ISwapRouter02(swapRouter).exactInputSingle(sp); + uint256 wethAfter = IERC20Min(weth).balanceOf(me); + + console.log('Swap: 1 TEST -> WETH'); + console.log(' amount out:', out); + console.log(' WETH delta:', wethAfter - wethBefore); + require(out > 0, 'swap returned zero'); + require(wethAfter > wethBefore, "WETH balance didn't increase"); + } +} diff --git a/script/smoke/V4SmokeTest.s.sol b/script/smoke/V4SmokeTest.s.sol new file mode 100644 index 00000000..130ec280 --- /dev/null +++ b/script/smoke/V4SmokeTest.s.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2 as console} from 'forge-std/Script.sol'; + +interface IERC20Min { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address) external view returns (uint256); +} + +interface IWETH9 is IERC20Min { + function deposit() external payable; +} + +interface IPermit2 { + function approve(address token, address spender, uint160 amount, uint48 expiration) external; +} + +interface INonfungiblePositionManagerV3 { + function WETH9() external view returns (address); +} + +// Mirrors PoolKey from v4-core +struct PoolKey { + address currency0; + address currency1; + uint24 fee; + int24 tickSpacing; + address hooks; +} + +interface IPositionManager { + function initializePool(PoolKey calldata key, uint160 sqrtPriceX96) external returns (int24); + function modifyLiquidities(bytes calldata unlockData, uint256 deadline) external payable; + function nextTokenId() external view returns (uint256); +} + +interface IStateView { + function getSlot0(bytes32 poolId) + external + view + returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee); + function getLiquidity(bytes32 poolId) external view returns (uint128 liquidity); +} + +interface IUniversalRouter { + function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable; +} + +// Matches IV4Router.ExactInputSingleParams — must encode this whole struct as a single value +struct ExactInputSingleParams { + PoolKey poolKey; + bool zeroForOne; + uint128 amountIn; + uint128 amountOutMinimum; + bytes hookData; +} + +contract TestToken { + string public name = 'SmokeV4 Token'; + string public symbol = 'SMK4'; + uint8 public decimals = 18; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + constructor(uint256 _s) { + totalSupply = _s; + balanceOf[msg.sender] = _s; + } + + function approve(address s, uint256 a) external returns (bool) { + allowance[msg.sender][s] = a; + return true; + } + + function transfer(address to, uint256 a) external returns (bool) { + balanceOf[msg.sender] -= a; + balanceOf[to] += a; + return true; + } + + function transferFrom(address f, address t, uint256 a) external returns (bool) { + if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= a; + balanceOf[f] -= a; + balanceOf[t] += a; + return true; + } +} + +contract V4SmokeTest is Script { + // v4 Action codes + uint8 constant MINT_POSITION = 0x02; + uint8 constant SWAP_EXACT_IN_SINGLE = 0x06; + uint8 constant SETTLE_ALL = 0x0c; + uint8 constant SETTLE_PAIR = 0x0d; + uint8 constant TAKE_ALL = 0x0f; + + // UR command code for V4 swap + uint8 constant UR_V4_SWAP = 0x10; + + uint24 constant FEE = 3000; + int24 constant TICK_SPACING = 60; + int24 constant TICK_LOWER = -887_220; + int24 constant TICK_UPPER = 887_220; + uint160 constant SQRT_PRICE_1_1 = 79_228_162_514_264_337_593_543_950_336; + + struct Env { + address weth; + address permit2; + address poolManager; + address positionManager; + address stateView; + address universalRouter; + } + + function run() public { + Env memory e = _loadEnv(); + + vm.startBroadcast(); + address me = msg.sender; + console.log('Chain:', block.chainid); + console.log('Deployer:', me); + _logEnv(e); + + IWETH9(e.weth).deposit{value: 0.0001 ether}(); + TestToken test = new TestToken(1_000_000 ether); + console.log('Test token:', address(test)); + + PoolKey memory key = _buildKey(address(test), e.weth); + _approvePermit2(address(test), e); + _initPool(key, e.positionManager); + uint256 tokenId = _mintPosition(key, address(test), e.weth, e.positionManager, me); + _verifyPool(key, e.stateView, tokenId); + _doSwap(key, address(test), e, me); + + console.log(''); + console.log('SUCCESS: v4 pool initialized, position minted, swap completed via UR'); + vm.stopBroadcast(); + } + + function _loadEnv() internal view returns (Env memory e) { + string memory chainIdStr = vm.toString(block.chainid); + string memory path = string.concat('./deployments/json/', chainIdStr, '.json'); + string memory json = vm.readFile(path); + + e.permit2 = vm.parseJsonAddress(json, '.latest.Permit2.address'); + e.poolManager = vm.parseJsonAddress(json, '.latest.PoolManager.address'); + e.positionManager = vm.parseJsonAddress(json, '.latest.PositionManager.address'); + e.stateView = vm.parseJsonAddress(json, '.latest.StateView.address'); + e.universalRouter = vm.parseJsonAddress(json, '.latest.UniversalRouter.address'); + + address v3npm = vm.parseJsonAddress(json, '.latest.NonfungiblePositionManager.address'); + e.weth = INonfungiblePositionManagerV3(v3npm).WETH9(); + + require(e.permit2 != address(0), 'Permit2 not in deployments JSON'); + require(e.poolManager != address(0), 'PoolManager not in deployments JSON'); + require(e.positionManager != address(0), 'PositionManager not in deployments JSON'); + require(e.stateView != address(0), 'StateView not in deployments JSON'); + require(e.universalRouter != address(0), 'UniversalRouter not in deployments JSON'); + require(e.weth != address(0), 'WETH not derivable from v3 NPM'); + } + + function _logEnv(Env memory e) internal pure { + console.log('PoolManager:', e.poolManager); + console.log('PositionManager:', e.positionManager); + console.log('StateView:', e.stateView); + console.log('UniversalRouter:', e.universalRouter); + console.log('Permit2:', e.permit2); + console.log('WETH:', e.weth); + } + + function _buildKey(address test, address weth) internal pure returns (PoolKey memory) { + (address c0, address c1) = weth < test ? (weth, test) : (test, weth); + return PoolKey({currency0: c0, currency1: c1, fee: FEE, tickSpacing: TICK_SPACING, hooks: address(0)}); + } + + function _approvePermit2(address test, Env memory e) internal { + IWETH9(e.weth).approve(e.permit2, type(uint256).max); + TestToken(test).approve(e.permit2, type(uint256).max); + // Allowance to PositionManager (for mint) + IPermit2(e.permit2).approve(e.weth, e.positionManager, type(uint160).max, type(uint48).max); + IPermit2(e.permit2).approve(test, e.positionManager, type(uint160).max, type(uint48).max); + // Allowance to UniversalRouter (for swap) + IPermit2(e.permit2).approve(e.weth, e.universalRouter, type(uint160).max, type(uint48).max); + IPermit2(e.permit2).approve(test, e.universalRouter, type(uint160).max, type(uint48).max); + console.log('Permit2 allowances granted to PositionManager + UR'); + } + + function _initPool(PoolKey memory key, address positionManager) internal { + IPositionManager(positionManager).initializePool(key, SQRT_PRICE_1_1); + console.log('Pool initialized at sqrtPriceX96 = SQRT_PRICE_1_1'); + } + + function _mintPosition(PoolKey memory key, address test, address weth, address positionManager, address me) + internal + returns (uint256 tokenId) + { + tokenId = IPositionManager(positionManager).nextTokenId(); + + bytes memory actions = abi.encodePacked(MINT_POSITION, SETTLE_PAIR); + bytes[] memory params = new bytes[](2); + uint256 liquidity = 5e13; + params[0] = + abi.encode(key, TICK_LOWER, TICK_UPPER, liquidity, type(uint128).max, type(uint128).max, me, bytes('')); + params[1] = abi.encode(key.currency0, key.currency1); + + IPositionManager(positionManager).modifyLiquidities(abi.encode(actions, params), block.timestamp + 3600); + console.log('Minted v4 position tokenId:', tokenId); + + // silence unused warnings + test; + weth; + } + + function _verifyPool(PoolKey memory key, address stateView, uint256 tokenId) internal view { + bytes32 poolId = keccak256(abi.encode(key)); + (uint160 sqrtPriceX96, int24 tick,,) = IStateView(stateView).getSlot0(poolId); + uint128 liq = IStateView(stateView).getLiquidity(poolId); + console.log('Pool slot0 (via StateView):'); + console.log(' sqrtPriceX96:', sqrtPriceX96); + console.log(' tick:', tick); + console.log(' pool liquidity:', liq); + require(sqrtPriceX96 == SQRT_PRICE_1_1, 'pool sqrtPrice mismatch'); + require(liq > 0, 'no liquidity in pool'); + require(tokenId > 0, 'no position minted'); + } + + function _doSwap(PoolKey memory key, address test, Env memory e, address me) internal { + // We swap TEST -> WETH. + // zeroForOne = true means selling currency0 for currency1 + // If test is currency0 → zeroForOne = true (selling TEST → WETH) + // If WETH is currency0 → zeroForOne = false (TEST is currency1, selling currency1 → currency0) + bool zeroForOne = test == key.currency0; + address tokenIn = test; + address tokenOut = e.weth; + + // SWAP_EXACT_IN_SINGLE expects abi.encode(struct), where the V4Router decoder + // reads the first word as a pointer to the struct data. Encoding the fields + // inline (abi.encode(key, zfo, amountIn, ...)) is NOT compatible with + // decodeSwapExactInSingleParams in CalldataDecoder. + ExactInputSingleParams memory swapParams = ExactInputSingleParams({ + poolKey: key, zeroForOne: zeroForOne, amountIn: 1e18, amountOutMinimum: 0, hookData: bytes('') + }); + + bytes memory actions = abi.encodePacked(SWAP_EXACT_IN_SINGLE, SETTLE_ALL, TAKE_ALL); + bytes[] memory params = new bytes[](3); + params[0] = abi.encode(swapParams); + params[1] = abi.encode(tokenIn, type(uint256).max); // SETTLE_ALL: (currency, maxAmount) + params[2] = abi.encode(tokenOut, uint256(0)); // TAKE_ALL: (currency, minAmount) + + bytes memory commands = abi.encodePacked(UR_V4_SWAP); + bytes[] memory inputs = new bytes[](1); + inputs[0] = abi.encode(actions, params); + + uint256 wethBefore = IERC20Min(e.weth).balanceOf(me); + IUniversalRouter(e.universalRouter).execute(commands, inputs, block.timestamp + 3600); + uint256 wethAfter = IERC20Min(e.weth).balanceOf(me); + + console.log('Swap via UR: 1 TEST -> WETH'); + console.log(' WETH delta:', wethAfter - wethBefore); + require(wethAfter > wethBefore, "WETH balance didn't increase after swap"); + } +} From 7c87d8d6d35612724b99dd557fe4560dfd8b9e13 Mon Sep 17 00:00:00 2001 From: david-uniswap <274080779+david-uniswap@users.noreply.github.com> Date: Tue, 26 May 2026 15:31:05 -0700 Subject: [PATCH 2/4] update V4SmokeTest ExactInputSingleParams for UR v2.1.1 UR v2.1.1 (PR #470 upstream) added a minHopPriceX36 field to the V4Router ExactInputSingleParams struct, between amountOutMinimum and hookData. The smoke test's local struct definition + instantiation needed to match or the V4Router decoder reads misaligned calldata and reverts on swap. Set to 0 to disable the per-hop price check (smoke tests don't care about slippage protection, they just need the swap to land). Verified end-to-end against the new UR v2.1.1 at 0x8876789976decbfcbbbe364623c63652db8c0904 (Robinhood Chain 4663): swap delta 49997492603174 matches v2.1.0 since the pool math is unchanged. Tested in simulation; onchain broadcast is left for the next chain that exercises the smoke tests post-merge. --- script/smoke/V4SmokeTest.s.sol | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/script/smoke/V4SmokeTest.s.sol b/script/smoke/V4SmokeTest.s.sol index 130ec280..6dbd6cad 100644 --- a/script/smoke/V4SmokeTest.s.sol +++ b/script/smoke/V4SmokeTest.s.sol @@ -47,12 +47,15 @@ interface IUniversalRouter { function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable; } -// Matches IV4Router.ExactInputSingleParams — must encode this whole struct as a single value +// Matches IV4Router.ExactInputSingleParams — must encode this whole struct as a single value. +// minHopPriceX36 was added in UR v2.1.1 (slot between amountOutMinimum and hookData). +// Set to 0 to disable the per-hop price check. struct ExactInputSingleParams { PoolKey poolKey; bool zeroForOne; uint128 amountIn; uint128 amountOutMinimum; + uint256 minHopPriceX36; bytes hookData; } @@ -240,7 +243,12 @@ contract V4SmokeTest is Script { // inline (abi.encode(key, zfo, amountIn, ...)) is NOT compatible with // decodeSwapExactInSingleParams in CalldataDecoder. ExactInputSingleParams memory swapParams = ExactInputSingleParams({ - poolKey: key, zeroForOne: zeroForOne, amountIn: 1e18, amountOutMinimum: 0, hookData: bytes('') + poolKey: key, + zeroForOne: zeroForOne, + amountIn: 1e18, + amountOutMinimum: 0, + minHopPriceX36: 0, // disable per-hop price check + hookData: bytes('') }); bytes memory actions = abi.encodePacked(SWAP_EXACT_IN_SINGLE, SETTLE_ALL, TAKE_ALL); From 86e6f14c727d19a114c9342c8d800ddd5a9a0f3c Mon Sep 17 00:00:00 2001 From: david-uniswap <274080779+david-uniswap@users.noreply.github.com> Date: Tue, 26 May 2026 18:03:20 -0700 Subject: [PATCH 3/4] add native-is-erc20 chain smoke test variants Adds V2/V3/V4 smoke test variants in script/smoke/native-is-erc20/ for chains where the native gas token is itself an ERC-20 (CELO, Arc, similar Circle partner chains). On these chains the WETH9 dependency is wired to UnsupportedProtocol (or similar) which reverts on deposit/withdraw, so the standard smoke tests halt at the WETH wrap step. These variants skip the WETH leg entirely and use two freshly-deployed TestTokens to exercise the same v2/v3/v4 + UR code paths. Chain- agnostic via vm.parseJsonAddress reads from deployments/json/.json, matching the parent PR's pattern. Tested end-to-end on Arc Mainnet (chain 5042). All three pass: - V2: pair created, 1000:1000 liquidity, swap 1 A -> 0.996 B (0.3% fee) - V3: pool init at 1:1, position minted, exactInputSingle 1 A -> 0.996 B - V4: pool init, modifyLiquidities mint with Permit2 dual-approval, swap via UR V4_SWAP command Co-Authored-By: Claude Opus 4.7 (1M context) --- .../V2SmokeNativeIsERC20.s.sol | 141 +++++++++++ .../V3SmokeNativeIsERC20.s.sol | 182 ++++++++++++++ .../V4SmokeNativeIsERC20.s.sol | 228 ++++++++++++++++++ 3 files changed, 551 insertions(+) create mode 100644 script/smoke/native-is-erc20/V2SmokeNativeIsERC20.s.sol create mode 100644 script/smoke/native-is-erc20/V3SmokeNativeIsERC20.s.sol create mode 100644 script/smoke/native-is-erc20/V4SmokeNativeIsERC20.s.sol diff --git a/script/smoke/native-is-erc20/V2SmokeNativeIsERC20.s.sol b/script/smoke/native-is-erc20/V2SmokeNativeIsERC20.s.sol new file mode 100644 index 00000000..1dcdcdd9 --- /dev/null +++ b/script/smoke/native-is-erc20/V2SmokeNativeIsERC20.s.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2 as console} from 'forge-std/Script.sol'; + +// Variant of V2SmokeTest for chains where the native gas token is itself +// an ERC-20 (CELO, Arc, ...). On these chains, the WETH9 dependency is wired +// to a placeholder (e.g., UnsupportedProtocol) that reverts on deposit/withdraw, +// so the standard V2SmokeTest halts at the WETH wrap step. +// +// This variant skips the WETH leg entirely and exercises pair creation, +// liquidity add, and a swap using two freshly-deployed TestTokens. The v2 +// pool mechanics are token-agnostic, so the same code paths get covered. + +interface IERC20Min { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); +} + +interface IV2Factory { + function getPair(address tokenA, address tokenB) external view returns (address); +} + +interface IV2Router02 { + function addLiquidity( + address tokenA, + address tokenB, + uint256 amountADesired, + uint256 amountBDesired, + uint256 amountAMin, + uint256 amountBMin, + address to, + uint256 deadline + ) external returns (uint256 amountA, uint256 amountB, uint256 liquidity); + + function swapExactTokensForTokens( + uint256 amountIn, + uint256 amountOutMin, + address[] calldata path, + address to, + uint256 deadline + ) external returns (uint256[] memory amounts); +} + +contract TestToken { + string public name; + string public symbol; + uint8 public decimals = 18; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + constructor(string memory n, string memory s, uint256 supply) { + name = n; + symbol = s; + totalSupply = supply; + balanceOf[msg.sender] = supply; + } + + function approve(address sp, uint256 a) external returns (bool) { + allowance[msg.sender][sp] = a; + return true; + } + + function transfer(address to, uint256 a) external returns (bool) { + balanceOf[msg.sender] -= a; + balanceOf[to] += a; + return true; + } + + function transferFrom(address f, address t, uint256 a) external returns (bool) { + if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= a; + balanceOf[f] -= a; + balanceOf[t] += a; + return true; + } +} + +contract V2SmokeNativeIsERC20 is Script { + function run() public { + // Resolve addresses from deployments JSON (chain-agnostic) + string memory chainIdStr = vm.toString(block.chainid); + string memory path = string.concat('./deployments/json/', chainIdStr, '.json'); + string memory json = vm.readFile(path); + address v2Factory = vm.parseJsonAddress(json, '.latest.UniswapV2Factory.address'); + address v2Router = vm.parseJsonAddress(json, '.latest.UniswapV2Router02.address'); + + require(v2Factory != address(0), 'UniswapV2Factory not in deployments JSON'); + require(v2Router != address(0), 'UniswapV2Router02 not in deployments JSON'); + + vm.startBroadcast(); + address me = msg.sender; + console.log('Chain:', block.chainid); + console.log('Deployer:', me); + console.log('V2 Factory:', v2Factory); + console.log('V2 Router02:', v2Router); + + // Two fresh TestTokens stand in for the WETH/test pair in the standard variant + TestToken a = new TestToken('SmokeV2A', 'SMK2A', 1_000_000 ether); + TestToken b = new TestToken('SmokeV2B', 'SMK2B', 1_000_000 ether); + console.log('TestTokenA:', address(a)); + console.log('TestTokenB:', address(b)); + + a.approve(v2Router, type(uint256).max); + b.approve(v2Router, type(uint256).max); + + (uint256 amtA, uint256 amtB, uint256 liq) = IV2Router02(v2Router).addLiquidity( + address(a), address(b), 1000 ether, 1000 ether, 0, 0, me, block.timestamp + 3600 + ); + console.log('Added v2 liquidity:'); + console.log(' amountA:', amtA); + console.log(' amountB:', amtB); + console.log(' LP tokens:', liq); + require(liq > 0, 'no LP minted'); + + address pair = IV2Factory(v2Factory).getPair(address(a), address(b)); + console.log('Pair address:', pair); + require(pair != address(0), 'pair should exist'); + + uint256 bBefore = b.balanceOf(me); + address[] memory swapPath = new address[](2); + swapPath[0] = address(a); + swapPath[1] = address(b); + uint256[] memory amounts = IV2Router02(v2Router).swapExactTokensForTokens( + 1 ether, 0, swapPath, me, block.timestamp + 3600 + ); + uint256 bAfter = b.balanceOf(me); + + console.log('Swap: 1 A -> B'); + console.log(' amount in:', amounts[0]); + console.log(' amount out:', amounts[1]); + console.log(' B delta:', bAfter - bBefore); + require(amounts[1] > 0, 'swap returned zero'); + require(bAfter > bBefore, "B balance didn't increase"); + + console.log(''); + console.log('SUCCESS: v2 (native-is-erc20 variant) pair created, liquidity added, swap completed'); + vm.stopBroadcast(); + } +} diff --git a/script/smoke/native-is-erc20/V3SmokeNativeIsERC20.s.sol b/script/smoke/native-is-erc20/V3SmokeNativeIsERC20.s.sol new file mode 100644 index 00000000..a3b80ebe --- /dev/null +++ b/script/smoke/native-is-erc20/V3SmokeNativeIsERC20.s.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2 as console} from 'forge-std/Script.sol'; + +// Variant of V3SmokeTest for native-is-ERC20 chains. Uses two fresh +// TestTokens to bypass the WETH wrap that reverts on these chains. +// Validates v3 Factory createPool, pool initialize, NPM mint, and +// SwapRouter02 exactInputSingle — the full v3 LP+swap surface minus WETH. + +interface IERC20Min { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address) external view returns (uint256); +} + +interface IV3Factory { + function createPool(address tokenA, address tokenB, uint24 fee) external returns (address pool); +} + +interface IUniswapV3Pool { + function initialize(uint160 sqrtPriceX96) external; +} + +interface INonfungiblePositionManager { + struct MintParams { + address token0; + address token1; + uint24 fee; + int24 tickLower; + int24 tickUpper; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + address recipient; + uint256 deadline; + } + function mint(MintParams calldata) external payable returns (uint256, uint128, uint256, uint256); +} + +interface ISwapRouter02 { + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + function exactInputSingle(ExactInputSingleParams calldata) external payable returns (uint256); +} + +contract TestToken { + string public name; + string public symbol; + uint8 public decimals = 18; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + constructor(string memory n, string memory s, uint256 supply) { + name = n; + symbol = s; + totalSupply = supply; + balanceOf[msg.sender] = supply; + } + + function approve(address sp, uint256 a) external returns (bool) { + allowance[msg.sender][sp] = a; + return true; + } + + function transfer(address to, uint256 a) external returns (bool) { + balanceOf[msg.sender] -= a; + balanceOf[to] += a; + return true; + } + + function transferFrom(address f, address t, uint256 a) external returns (bool) { + if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= a; + balanceOf[f] -= a; + balanceOf[t] += a; + return true; + } +} + +contract V3SmokeNativeIsERC20 is Script { + uint24 constant FEE = 3000; + int24 constant TICK_LOWER = -887_220; + int24 constant TICK_UPPER = 887_220; + uint160 constant SQRT_PRICE_1_1 = 79_228_162_514_264_337_593_543_950_336; + + address v3Factory; + address npm; + address swapRouter02; + + function run() public { + // Resolve addresses from deployments JSON (chain-agnostic) + string memory chainIdStr = vm.toString(block.chainid); + string memory path = string.concat('./deployments/json/', chainIdStr, '.json'); + string memory json = vm.readFile(path); + v3Factory = vm.parseJsonAddress(json, '.latest.UniswapV3Factory.address'); + npm = vm.parseJsonAddress(json, '.latest.NonfungiblePositionManager.address'); + swapRouter02 = vm.parseJsonAddress(json, '.latest.SwapRouter02.address'); + + require(v3Factory != address(0), 'UniswapV3Factory not in deployments JSON'); + require(npm != address(0), 'NonfungiblePositionManager not in deployments JSON'); + require(swapRouter02 != address(0), 'SwapRouter02 not in deployments JSON'); + + vm.startBroadcast(); + address me = msg.sender; + console.log('Chain:', block.chainid); + console.log('Deployer:', me); + console.log('V3 Factory:', v3Factory); + console.log('NPM:', npm); + console.log('SwapRouter02:', swapRouter02); + + TestToken a = new TestToken('SmokeV3A', 'SMK3A', 1_000_000 ether); + TestToken b = new TestToken('SmokeV3B', 'SMK3B', 1_000_000 ether); + console.log('Token A:', address(a)); + console.log('Token B:', address(b)); + + (address t0, address t1) = + address(a) < address(b) ? (address(a), address(b)) : (address(b), address(a)); + + address pool = IV3Factory(v3Factory).createPool(t0, t1, FEE); + IUniswapV3Pool(pool).initialize(SQRT_PRICE_1_1); + console.log('Pool:', pool); + + a.approve(npm, type(uint256).max); + b.approve(npm, type(uint256).max); + _mint(t0, t1, me); + _swap(a, b, me); + + console.log(''); + console.log('SUCCESS: v3 (native-is-erc20 variant) pool created, position minted, swap completed'); + vm.stopBroadcast(); + } + + function _mint(address t0, address t1, address me) internal { + INonfungiblePositionManager.MintParams memory mp = INonfungiblePositionManager.MintParams({ + token0: t0, + token1: t1, + fee: FEE, + tickLower: TICK_LOWER, + tickUpper: TICK_UPPER, + amount0Desired: 1000 ether, + amount1Desired: 1000 ether, + amount0Min: 0, + amount1Min: 0, + recipient: me, + deadline: block.timestamp + 3600 + }); + (uint256 tokenId, uint128 liq,,) = INonfungiblePositionManager(npm).mint(mp); + console.log('Position tokenId:', tokenId); + console.log(' liquidity:', liq); + require(liq > 0, 'no v3 liquidity minted'); + } + + function _swap(TestToken a, TestToken b, address me) internal { + a.approve(swapRouter02, type(uint256).max); + uint256 bBefore = b.balanceOf(me); + ISwapRouter02.ExactInputSingleParams memory sp = ISwapRouter02.ExactInputSingleParams({ + tokenIn: address(a), + tokenOut: address(b), + fee: FEE, + recipient: me, + amountIn: 1 ether, + amountOutMinimum: 0, + sqrtPriceLimitX96: 0 + }); + uint256 out = ISwapRouter02(swapRouter02).exactInputSingle(sp); + uint256 bAfter = b.balanceOf(me); + + console.log('Swap: 1 A -> B'); + console.log(' amount out:', out); + console.log(' B delta:', bAfter - bBefore); + require(out > 0, 'swap returned zero'); + require(bAfter > bBefore, "B balance didn't increase"); + } +} diff --git a/script/smoke/native-is-erc20/V4SmokeNativeIsERC20.s.sol b/script/smoke/native-is-erc20/V4SmokeNativeIsERC20.s.sol new file mode 100644 index 00000000..f9765dde --- /dev/null +++ b/script/smoke/native-is-erc20/V4SmokeNativeIsERC20.s.sol @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2 as console} from 'forge-std/Script.sol'; + +// Variant of V4SmokeTest for native-is-ERC20 chains. Uses two fresh +// TestTokens to bypass the WETH wrap. Exercises PoolManager init, +// Permit2 dual-approval (PositionManager + UR), modifyLiquidities mint, +// StateView reads, and UR V4_SWAP — the same end-to-end surface as the +// standard variant minus the WETH wrap. + +interface IERC20Min { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address) external view returns (uint256); +} + +interface IPermit2 { + function approve(address token, address spender, uint160 amount, uint48 expiration) external; +} + +struct PoolKey { + address currency0; + address currency1; + uint24 fee; + int24 tickSpacing; + address hooks; +} + +interface IPositionManager { + function initializePool(PoolKey calldata key, uint160 sqrtPriceX96) external returns (int24); + function modifyLiquidities(bytes calldata unlockData, uint256 deadline) external payable; + function nextTokenId() external view returns (uint256); +} + +interface IStateView { + function getSlot0(bytes32 poolId) + external + view + returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee); + function getLiquidity(bytes32 poolId) external view returns (uint128 liquidity); +} + +interface IUniversalRouter { + function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable; +} + +// IV4Router.ExactInputSingleParams (UR v2.1.1 layout with minHopPriceX36) +struct ExactInputSingleParams { + PoolKey poolKey; + bool zeroForOne; + uint128 amountIn; + uint128 amountOutMinimum; + uint256 minHopPriceX36; + bytes hookData; +} + +contract TestToken { + string public name; + string public symbol; + uint8 public decimals = 18; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + constructor(string memory n, string memory s, uint256 supply) { + name = n; + symbol = s; + totalSupply = supply; + balanceOf[msg.sender] = supply; + } + + function approve(address sp, uint256 a) external returns (bool) { + allowance[msg.sender][sp] = a; + return true; + } + + function transfer(address to, uint256 a) external returns (bool) { + balanceOf[msg.sender] -= a; + balanceOf[to] += a; + return true; + } + + function transferFrom(address f, address t, uint256 a) external returns (bool) { + if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= a; + balanceOf[f] -= a; + balanceOf[t] += a; + return true; + } +} + +contract V4SmokeNativeIsERC20 is Script { + // v4 action codes + uint8 constant MINT_POSITION = 0x02; + uint8 constant SWAP_EXACT_IN_SINGLE = 0x06; + uint8 constant SETTLE_ALL = 0x0c; + uint8 constant SETTLE_PAIR = 0x0d; + uint8 constant TAKE_ALL = 0x0f; + uint8 constant UR_V4_SWAP = 0x10; + + uint24 constant FEE = 3000; + int24 constant TICK_SPACING = 60; + int24 constant TICK_LOWER = -887_220; + int24 constant TICK_UPPER = 887_220; + uint160 constant SQRT_PRICE_1_1 = 79_228_162_514_264_337_593_543_950_336; + + address permit2; + address positionManager; + address stateView; + address universalRouter; + + function run() public { + // Resolve addresses from deployments JSON (chain-agnostic) + string memory chainIdStr = vm.toString(block.chainid); + string memory path = string.concat('./deployments/json/', chainIdStr, '.json'); + string memory json = vm.readFile(path); + permit2 = vm.parseJsonAddress(json, '.latest.Permit2.address'); + positionManager = vm.parseJsonAddress(json, '.latest.PositionManager.address'); + stateView = vm.parseJsonAddress(json, '.latest.StateView.address'); + universalRouter = vm.parseJsonAddress(json, '.latest.UniversalRouter.address'); + + require(permit2 != address(0), 'Permit2 not in deployments JSON'); + require(positionManager != address(0), 'PositionManager not in deployments JSON'); + require(stateView != address(0), 'StateView not in deployments JSON'); + require(universalRouter != address(0), 'UniversalRouter not in deployments JSON'); + + vm.startBroadcast(); + address me = msg.sender; + console.log('Chain:', block.chainid); + console.log('Deployer:', me); + console.log('PoolManager (via PM):', positionManager); + console.log('StateView:', stateView); + console.log('UniversalRouter:', universalRouter); + console.log('Permit2:', permit2); + + TestToken a = new TestToken('SmokeV4A', 'SMK4A', 1_000_000 ether); + TestToken b = new TestToken('SmokeV4B', 'SMK4B', 1_000_000 ether); + console.log('Token A:', address(a)); + console.log('Token B:', address(b)); + + (address c0, address c1) = + address(a) < address(b) ? (address(a), address(b)) : (address(b), address(a)); + PoolKey memory key = PoolKey({ + currency0: c0, + currency1: c1, + fee: FEE, + tickSpacing: TICK_SPACING, + hooks: address(0) + }); + + _approve(a, b); + IPositionManager(positionManager).initializePool(key, SQRT_PRICE_1_1); + console.log('Pool initialized at sqrtPriceX96 = SQRT_PRICE_1_1'); + + _mintPosition(key, c0, c1, me); + _verifyPool(key); + _swap(key, a, b, c0, me); + + console.log(''); + console.log('SUCCESS: v4 (native-is-erc20 variant) pool init + position mint + UR swap completed'); + vm.stopBroadcast(); + } + + function _approve(TestToken a, TestToken b) internal { + a.approve(permit2, type(uint256).max); + b.approve(permit2, type(uint256).max); + IPermit2(permit2).approve(address(a), positionManager, type(uint160).max, type(uint48).max); + IPermit2(permit2).approve(address(b), positionManager, type(uint160).max, type(uint48).max); + IPermit2(permit2).approve(address(a), universalRouter, type(uint160).max, type(uint48).max); + IPermit2(permit2).approve(address(b), universalRouter, type(uint160).max, type(uint48).max); + console.log('Permit2 allowances granted (PositionManager + UR)'); + } + + function _mintPosition(PoolKey memory key, address c0, address c1, address me) internal { + uint256 tokenId = IPositionManager(positionManager).nextTokenId(); + bytes memory mintActions = abi.encodePacked(MINT_POSITION, SETTLE_PAIR); + bytes[] memory mintParams = new bytes[](2); + uint256 liquidity = 5e13; + mintParams[0] = abi.encode( + key, TICK_LOWER, TICK_UPPER, liquidity, type(uint128).max, type(uint128).max, me, bytes('') + ); + mintParams[1] = abi.encode(c0, c1); + IPositionManager(positionManager).modifyLiquidities( + abi.encode(mintActions, mintParams), block.timestamp + 3600 + ); + console.log('Minted v4 position tokenId:', tokenId); + } + + function _verifyPool(PoolKey memory key) internal view { + bytes32 poolId = keccak256(abi.encode(key)); + (uint160 sqrtPriceX96, int24 tick,,) = IStateView(stateView).getSlot0(poolId); + uint128 poolLiq = IStateView(stateView).getLiquidity(poolId); + console.log('Pool slot0:'); + console.log(' sqrtPriceX96:', sqrtPriceX96); + console.log(' tick:', tick); + console.log(' pool liquidity:', poolLiq); + require(sqrtPriceX96 == SQRT_PRICE_1_1, 'pool sqrtPrice mismatch'); + require(poolLiq > 0, 'no liquidity in pool'); + } + + function _swap(PoolKey memory key, TestToken a, TestToken b, address c0, address me) internal { + ExactInputSingleParams memory swapParams = ExactInputSingleParams({ + poolKey: key, + zeroForOne: address(a) == c0, + amountIn: 1e18, + amountOutMinimum: 0, + minHopPriceX36: 0, + hookData: bytes('') + }); + + bytes memory swapActions = abi.encodePacked(SWAP_EXACT_IN_SINGLE, SETTLE_ALL, TAKE_ALL); + bytes[] memory swapInnerParams = new bytes[](3); + swapInnerParams[0] = abi.encode(swapParams); + swapInnerParams[1] = abi.encode(address(a), type(uint256).max); + swapInnerParams[2] = abi.encode(address(b), uint256(0)); + + bytes memory commands = abi.encodePacked(UR_V4_SWAP); + bytes[] memory inputs = new bytes[](1); + inputs[0] = abi.encode(swapActions, swapInnerParams); + + uint256 bBefore = b.balanceOf(me); + IUniversalRouter(universalRouter).execute(commands, inputs, block.timestamp + 3600); + uint256 bAfter = b.balanceOf(me); + + console.log('Swap: 1 A -> B via UR.V4_SWAP'); + console.log(' B delta:', bAfter - bBefore); + require(bAfter > bBefore, "B balance didn't increase after swap"); + } +} From 3723978d0eb1670c6a03c194e7a9d0d4bf30d782 Mon Sep 17 00:00:00 2001 From: david-uniswap <274080779+david-uniswap@users.noreply.github.com> Date: Thu, 28 May 2026 10:48:09 -0700 Subject: [PATCH 4/4] forge fmt native-is-erc20 smoke tests Co-Authored-By: Claude Opus 4.7 (1M context) --- .../V2SmokeNativeIsERC20.s.sol | 10 ++++----- .../V3SmokeNativeIsERC20.s.sol | 3 +-- .../V4SmokeNativeIsERC20.s.sol | 21 ++++++------------- 3 files changed, 11 insertions(+), 23 deletions(-) diff --git a/script/smoke/native-is-erc20/V2SmokeNativeIsERC20.s.sol b/script/smoke/native-is-erc20/V2SmokeNativeIsERC20.s.sol index 1dcdcdd9..540efc2f 100644 --- a/script/smoke/native-is-erc20/V2SmokeNativeIsERC20.s.sol +++ b/script/smoke/native-is-erc20/V2SmokeNativeIsERC20.s.sol @@ -105,9 +105,8 @@ contract V2SmokeNativeIsERC20 is Script { a.approve(v2Router, type(uint256).max); b.approve(v2Router, type(uint256).max); - (uint256 amtA, uint256 amtB, uint256 liq) = IV2Router02(v2Router).addLiquidity( - address(a), address(b), 1000 ether, 1000 ether, 0, 0, me, block.timestamp + 3600 - ); + (uint256 amtA, uint256 amtB, uint256 liq) = IV2Router02(v2Router) + .addLiquidity(address(a), address(b), 1000 ether, 1000 ether, 0, 0, me, block.timestamp + 3600); console.log('Added v2 liquidity:'); console.log(' amountA:', amtA); console.log(' amountB:', amtB); @@ -122,9 +121,8 @@ contract V2SmokeNativeIsERC20 is Script { address[] memory swapPath = new address[](2); swapPath[0] = address(a); swapPath[1] = address(b); - uint256[] memory amounts = IV2Router02(v2Router).swapExactTokensForTokens( - 1 ether, 0, swapPath, me, block.timestamp + 3600 - ); + uint256[] memory amounts = + IV2Router02(v2Router).swapExactTokensForTokens(1 ether, 0, swapPath, me, block.timestamp + 3600); uint256 bAfter = b.balanceOf(me); console.log('Swap: 1 A -> B'); diff --git a/script/smoke/native-is-erc20/V3SmokeNativeIsERC20.s.sol b/script/smoke/native-is-erc20/V3SmokeNativeIsERC20.s.sol index a3b80ebe..6b2d19cb 100644 --- a/script/smoke/native-is-erc20/V3SmokeNativeIsERC20.s.sol +++ b/script/smoke/native-is-erc20/V3SmokeNativeIsERC20.s.sol @@ -121,8 +121,7 @@ contract V3SmokeNativeIsERC20 is Script { console.log('Token A:', address(a)); console.log('Token B:', address(b)); - (address t0, address t1) = - address(a) < address(b) ? (address(a), address(b)) : (address(b), address(a)); + (address t0, address t1) = address(a) < address(b) ? (address(a), address(b)) : (address(b), address(a)); address pool = IV3Factory(v3Factory).createPool(t0, t1, FEE); IUniswapV3Pool(pool).initialize(SQRT_PRICE_1_1); diff --git a/script/smoke/native-is-erc20/V4SmokeNativeIsERC20.s.sol b/script/smoke/native-is-erc20/V4SmokeNativeIsERC20.s.sol index f9765dde..38c5ca48 100644 --- a/script/smoke/native-is-erc20/V4SmokeNativeIsERC20.s.sol +++ b/script/smoke/native-is-erc20/V4SmokeNativeIsERC20.s.sol @@ -137,15 +137,9 @@ contract V4SmokeNativeIsERC20 is Script { console.log('Token A:', address(a)); console.log('Token B:', address(b)); - (address c0, address c1) = - address(a) < address(b) ? (address(a), address(b)) : (address(b), address(a)); - PoolKey memory key = PoolKey({ - currency0: c0, - currency1: c1, - fee: FEE, - tickSpacing: TICK_SPACING, - hooks: address(0) - }); + (address c0, address c1) = address(a) < address(b) ? (address(a), address(b)) : (address(b), address(a)); + PoolKey memory key = + PoolKey({currency0: c0, currency1: c1, fee: FEE, tickSpacing: TICK_SPACING, hooks: address(0)}); _approve(a, b); IPositionManager(positionManager).initializePool(key, SQRT_PRICE_1_1); @@ -175,13 +169,10 @@ contract V4SmokeNativeIsERC20 is Script { bytes memory mintActions = abi.encodePacked(MINT_POSITION, SETTLE_PAIR); bytes[] memory mintParams = new bytes[](2); uint256 liquidity = 5e13; - mintParams[0] = abi.encode( - key, TICK_LOWER, TICK_UPPER, liquidity, type(uint128).max, type(uint128).max, me, bytes('') - ); + mintParams[0] = + abi.encode(key, TICK_LOWER, TICK_UPPER, liquidity, type(uint128).max, type(uint128).max, me, bytes('')); mintParams[1] = abi.encode(c0, c1); - IPositionManager(positionManager).modifyLiquidities( - abi.encode(mintActions, mintParams), block.timestamp + 3600 - ); + IPositionManager(positionManager).modifyLiquidities(abi.encode(mintActions, mintParams), block.timestamp + 3600); console.log('Minted v4 position tokenId:', tokenId); }