diff --git a/script/smoke/LocalV4Deploy.s.sol b/script/smoke/LocalV4Deploy.s.sol new file mode 100644 index 00000000..4abd3345 --- /dev/null +++ b/script/smoke/LocalV4Deploy.s.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Script, console2 as console} from 'forge-std/Script.sol'; + +/// @notice Minimal mintable ERC-20 for local testing (configurable decimals). +contract FakeToken { + string public name; + string public symbol; + uint8 public decimals; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + constructor(string memory n, string memory s, uint8 d) { + name = n; + symbol = s; + decimals = d; + } + + function mint(address to, uint256 a) external { + balanceOf[to] += a; + totalSupply += a; + } + + 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; + } +} + +/// @notice Stands up a full local v4 stack + two fake stablecoins, then writes a deployments JSON +/// (chain 31337) in the exact shape SeedStablePoolV4 reads. +/// @dev The v4 contracts pin solc 0.8.26/cancun and Permit2 pins 0.8.17, which conflict with each other +/// and with this script. We sidestep all of it by deploying every protocol contract from its +/// PRECOMPILED bytecode (out/*.json) via vm.getCode, so no protocol source enters this compile unit. +contract LocalV4Deploy is Script { + uint256 constant UNSUBSCRIBE_GAS_LIMIT = 100_000; + + function run() public { + vm.startBroadcast(); + address me = msg.sender; + + // Reference artifacts by their out/ path so vm.getCode reads them from disk directly. This works + // even when these packages are excluded from the script build via --skip (needed to dodge the + // permit2/v4 solc-version conflict in a full-project compile). + address permit2 = _deploy(vm.getCode('out/Permit2.sol/Permit2.json')); + address poolManager = + _deploy(bytes.concat(vm.getCode('out/PoolManager.sol/PoolManager.json'), abi.encode(me))); + address positionManager = _deploy( + bytes.concat( + vm.getCode('out/PositionManager.sol/PositionManager.json'), + abi.encode(poolManager, permit2, UNSUBSCRIBE_GAS_LIMIT, address(0), address(0)) + ) + ); + address stateView = + _deploy(bytes.concat(vm.getCode('out/StateView.sol/StateView.json'), abi.encode(poolManager))); + + // Fake stablecoins (6 decimals, like USDC/EURC). "Worth" is set later via the seed script's prices. + FakeToken abc = new FakeToken('ABC Dollar', 'ABC', 6); + FakeToken bbc = new FakeToken('BBC Krona', 'BBC', 6); + abc.mint(me, 1_000_000e6); + bbc.mint(me, 1_000_000e6); + + vm.stopBroadcast(); + + console.log('Permit2:', permit2); + console.log('PoolManager:', poolManager); + console.log('PositionManager:', positionManager); + console.log('StateView:', stateView); + console.log('ABC ($1.00):', address(abc)); + console.log('BBC ($0.95):', address(bbc)); + } + + function _deploy(bytes memory initcode) internal returns (address addr) { + assembly { + addr := create(0, add(initcode, 0x20), mload(initcode)) + } + require(addr != address(0), 'deploy failed'); + } +} diff --git a/script/smoke/SEED_POOL_GUIDE.md b/script/smoke/SEED_POOL_GUIDE.md new file mode 100644 index 00000000..780c1ba3 --- /dev/null +++ b/script/smoke/SEED_POOL_GUIDE.md @@ -0,0 +1,146 @@ +# Seeding a USDC/EURC pool on Arc (Uniswap v4, chain 5042) + +Guide for creating and funding a single test pool without a UI, using Foundry. Written for the +Arc / LI.FI same-chain swap testing requirement. Uses **Uniswap v4**. + +## Why seed small first + +The pool price is NOT fixed: it floats with every swap, like any AMM. The only thing that happens once +is **initialization** — `initializePool` sets the *starting* price and can only be called once per pool, +so you can't re-initialize to reset a bad starting price. + +If you initialize at the wrong starting price, the pool is mispriced versus the market. An arbitrageur +trades against it to pull it to the true price and keeps the difference, and **that loss scales with how +much liquidity you posted**. Correcting a bad starting price yourself means swapping the pool back, which +costs fees/spread. With a tiny position that's cheap; with a big one it's a real loss. + +So the safe sequence is: + +1. **Initialize + seed a tiny amount first** (a few dollars), using a live FX quote for the price. If the + starting price is off, only a few dollars are exposed and it's cheap to nudge back. +2. **Run a test swap / let LI.FI route against it** to confirm everything works. +3. **Top up to the full ~$1k/$1k** once confirmed. The pool is already initialized, so the top-up adds + liquidity at the pool's current live price (wherever swaps have left it); it does not re-initialize. + +The script below is built to be run exactly this way: run it once with a small budget, then run it +again with the full budget. + +## You never touch ticks or sqrt prices + +You only ever provide **USD prices** (and a USD budget). The script converts those into the v4 +sqrtPriceX96, the tick range, and the liquidity for you. Use a live FX quote for the prices on the first +run, since that run sets the pool's starting price (see above). + +## Deployed Uniswap v4 addresses on Arc (chain 5042) + +Explorer: https://explorer.arc.io · resolved automatically by the script from `deployments/json/5042.json`. + +| Contract | Address | +|---|---| +| PoolManager | `0x8366a39cc670b4001a1121b8f6a443a643e40951` | +| PositionManager | `0x6049c9a0e26405c0985f9e3685c87d0ae917f82b` | +| StateView | `0xf3334192d15450cdd385c8b70e03f9a6bd9e673b` | +| V4Quoter | `0x8dc178efb8111bb0973dd9d722ebeff267c98f94` | +| UniversalRouter | `0x4fca4a51ab4f23a7447b3284fbd7d73289a89fb1` | +| Permit2 | `0x000000000022D473030F116dDEE9F6B43aC78BA3` | + +## Step 0 — confirm tokens and gas (do this first) + +We never hardcode token addresses. Confirm the canonical USDC and EURC addresses on Arc from Circle's +docs, then verify onchain: + +```bash +export ARC_RPC= +cast call "symbol()(string)" --rpc-url $ARC_RPC # expect USDC +cast call "decimals()(uint8)" --rpc-url $ARC_RPC # expect 6 +cast call "symbol()(string)" --rpc-url $ARC_RPC # expect EURC +cast call "decimals()(uint8)" --rpc-url $ARC_RPC # expect 6 +``` + +Both are 6 decimals, so the script's equal-decimals path applies. + +**Gas note:** Arc's native gas token is an ERC-20 (not ETH). Your funding EOA must hold some of Arc's +native token to pay for transactions. The USDC/EURC pool itself never touches the native token. + +## The script + +`script/smoke/SeedStablePoolV4.s.sol` — creates the pool (first run) and funds it, in one transaction. +It mirrors the v4 encoding from the smoke test that is already proven to work on Arc +(`script/smoke/native-is-erc20/V4SmokeNativeIsERC20.s.sol`): same action codes, same +`modifyLiquidities` encoding, same Permit2 dual-approval. It uses the 0.05% fee tier and a full-range +position. + +### Inputs (all USD figures scaled by 1e8, "Chainlink style") + +| Env var | Meaning | Example | +|---|---|---| +| `TOKEN_A` | one token address (e.g. USDC) | `0x...` | +| `TOKEN_B` | the other token address (e.g. EURC) | `0x...` | +| `PRICE_A_USD_8DP` | USD price of 1 TOKEN_A × 1e8 | `$1.00` → `100000000` | +| `PRICE_B_USD_8DP` | USD price of 1 TOKEN_B × 1e8 | `$1.08` → `108000000` | +| `USD_PER_SIDE_8DP` | USD value to deposit per side × 1e8 | `$2` test → `200000000`; `$1000` → `100000000000` | + +The rule for the 1e8 scale: **multiply the dollar figure by 100000000**. Use a real EUR/USD quote for +`PRICE_B_USD_8DP` at run time. + +### Run 1 — tiny test seed (this locks the price) + +```bash +export ARC_RPC= +export TOKEN_A= +export TOKEN_B= +export PRICE_A_USD_8DP=100000000 # USDC $1.00 +export PRICE_B_USD_8DP=108000000 # EURC $1.08 <-- use the live rate +export USD_PER_SIDE_8DP=200000000 # $2 per side for the test + +# Dry run first (no broadcast) — read the logged amounts/price, confirm they look right: +forge script script/smoke/SeedStablePoolV4.s.sol --rpc-url $ARC_RPC --sender + +# Broadcast: +forge script script/smoke/SeedStablePoolV4.s.sol \ + --rpc-url $ARC_RPC --account --sender --broadcast +``` + +> Arc gas: if a tx gets stuck, prior Arc deploys have used `--legacy --gas-price 200000000`. Add those +> flags if you hit replacement/underpriced errors. + +### Test it + +Quote a small swap to prove the pool is routable (V4Quoter): + +```bash +cast call 0x8dc178efb8111bb0973dd9d722ebeff267c98f94 \ + "quoteExactInputSingle(((address,address,uint24,int24,address),bool,uint128,bytes))(uint256,uint256)" \ + "((,,500,10,0x0000000000000000000000000000000000000000),,1000000,0x)" \ + --rpc-url $ARC_RPC +``` + +``/`` are USDC/EURC sorted by address (lower address is c0). `zeroForOne=true` means selling c0. +Then point LI.FI at the pool for their same-chain swap test. + +### Run 2 — top up to full size (price unchanged) + +Re-run the exact same command with the real budget. The script detects the pool is already initialized, +reads the live price from StateView, and adds liquidity there without re-pricing: + +```bash +export USD_PER_SIDE_8DP=100000000000 # $1000 per side +forge script script/smoke/SeedStablePoolV4.s.sol \ + --rpc-url $ARC_RPC --account --sender --broadcast +``` + +## What the script does (and safety checks) + +- Resolves all infra addresses from `deployments/json/5042.json` (nothing hardcoded). +- Sorts the tokens (currency0 < currency1) and keeps prices/amounts aligned. +- Requires equal decimals (the USD→price math assumes it). +- First run: computes sqrtPriceX96 from the USD prices and initializes. Later runs: funds at the live price. +- Derives token amounts from `USD_PER_SIDE_8DP` and the prices, and the liquidity from those amounts. +- Permit2 dual-approval, then `modifyLiquidities` (MINT_POSITION + SETTLE_PAIR), full range. +- Checks your balances before minting and asserts pool liquidity increased after. + +## References + +- Uniswap v4 docs: https://docs.uniswap.org/contracts/v4/overview +- Proven-on-Arc v4 flow: `script/smoke/native-is-erc20/V4SmokeNativeIsERC20.s.sol` +- All Arc deployments: `deployments/5042.md` diff --git a/script/smoke/SeedStablePoolV4.s.sol b/script/smoke/SeedStablePoolV4.s.sol new file mode 100644 index 00000000..be226cb9 --- /dev/null +++ b/script/smoke/SeedStablePoolV4.s.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: MIT +// ^0.8.20 to match the sibling smoke scripts in this directory (these are not deployed). +pragma solidity ^0.8.20; + +import {Script, console2 as console} from 'forge-std/Script.sol'; + +// Real v4 math libraries (briefcase copies are self-contained, pragma ^0.8.0). +import {TickMath} from '../../src/briefcase/protocols/v4-core/libraries/TickMath.sol'; +import {FullMath} from '../../src/briefcase/protocols/v4-core/libraries/FullMath.sol'; +import {LiquidityAmounts} from '../../src/briefcase/protocols/v4-periphery/libraries/LiquidityAmounts.sol'; + +interface IERC20Min { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address) external view returns (uint256); + function symbol() external view returns (string memory); + function decimals() external view returns (uint8); +} + +interface IPermit2 { + function approve(address token, address spender, uint160 amount, uint48 expiration) external; +} + +// 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); +} + +/// @notice Creates AND funds a Uniswap v4 pool for an equal-decimals stable pair (e.g. USDC/EURC). +/// +/// @dev USD-NATIVE INTERFACE. The operator only ever supplies plain USD figures. They never compute a +/// tick or a sqrtPriceX96 — this script derives both from the USD prices. +/// +/// Env vars (all USD figures are scaled by 1e8, "Chainlink style": $1.00 = 100000000): +/// TOKEN_A, TOKEN_B token addresses (order does not matter; sorted internally) +/// PRICE_A_USD_8DP USD price of 1 whole TOKEN_A, x1e8 (e.g. USDC $1.00 -> 100000000) +/// PRICE_B_USD_8DP USD price of 1 whole TOKEN_B, x1e8 (e.g. EURC $1.08 -> 108000000) +/// USD_PER_SIDE_8DP target USD value to deposit on EACH side, x1e8 ($1000 -> 100000000000) +/// +/// STAGED FUNDING (run this script more than once): +/// - 1st run sets the pool's STARTING price from the USD prices. Initialization happens once per +/// pool (you cannot re-initialize), but the price floats with swaps afterward like any AMM. +/// Use a real FX quote and start with a SMALL USD_PER_SIDE: if the starting price is off, the +/// pool gets arbitraged and the loss scales with the liquidity posted, so keep the first run tiny. +/// - After confirming a test swap routes, run again with the full USD_PER_SIDE. The pool is already +/// initialized, so this run skips init and adds liquidity at the pool's CURRENT live price +/// (read from StateView) — whatever swaps have left it at. +contract SeedStablePoolV4 is Script { + // v4 Action codes + uint8 constant MINT_POSITION = 0x02; + uint8 constant SETTLE_PAIR = 0x0d; + + // Sensible stable-pair params. In v4 ANY fee/tickSpacing combo is allowed (no externally-enabled tiers). + uint24 constant FEE = 500; // 0.05% + int24 constant TICK_SPACING = 10; + + uint256 constant USD_SCALE = 1e8; // all *_8DP env inputs use this scale + + struct Env { + address permit2; + address poolManager; + address positionManager; + address stateView; + } + + function run() public { + Env memory e = _loadEnv(); + + // --- Inputs (never hardcoded) --- + address tokenA = vm.envAddress('TOKEN_A'); + address tokenB = vm.envAddress('TOKEN_B'); + uint256 priceA8 = vm.envUint('PRICE_A_USD_8DP'); + uint256 priceB8 = vm.envUint('PRICE_B_USD_8DP'); + uint256 usdPerSide8 = vm.envUint('USD_PER_SIDE_8DP'); + + require(tokenA != address(0) && tokenB != address(0), 'token addr zero'); + require(tokenA != tokenB, 'tokens identical'); + require(priceA8 > 0 && priceB8 > 0, 'price zero'); + require(usdPerSide8 > 0, 'budget zero'); + + // Order by address: currency0 < currency1. Reorder prices to match. + (address c0, address c1, uint256 price0_8, uint256 price1_8) = + tokenA < tokenB ? (tokenA, tokenB, priceA8, priceB8) : (tokenB, tokenA, priceB8, priceA8); + + // The 1:1-cancellation in the price/amount math below is only valid when both tokens share decimals. + uint8 dec0 = IERC20Min(c0).decimals(); + uint8 dec1 = IERC20Min(c1).decimals(); + require(dec0 == dec1, 'unequal decimals: this script supports equal-decimal stable pairs only'); + + // Convert the USD budget into raw token amounts: amount_raw = usdPerSide / price * 10^decimals. + uint256 unit = 10 ** uint256(dec0); + uint256 amount0 = FullMath.mulDiv(usdPerSide8, unit, price0_8); + uint256 amount1 = FullMath.mulDiv(usdPerSide8, unit, price1_8); + require(amount0 > 0 && amount1 > 0, 'computed amount is zero (budget too small)'); + + PoolKey memory key = + PoolKey({currency0: c0, currency1: c1, fee: FEE, tickSpacing: TICK_SPACING, hooks: address(0)}); + bytes32 poolId = keccak256(abi.encode(key)); + + // If the pool is already initialized, fund at its LIVE price (top-up run); otherwise compute the + // initialization price from the USD prices (first run). + (uint160 livePrice,,,) = IStateView(e.stateView).getSlot0(poolId); + bool alreadyInitialized = livePrice != 0; + uint160 sqrtPriceX96 = alreadyInitialized ? livePrice : _sqrtPriceFromUsd(price0_8, price1_8); + + // Full-range ticks aligned to tickSpacing. + int24 tickLower = TickMath.minUsableTick(TICK_SPACING); + int24 tickUpper = TickMath.maxUsableTick(TICK_SPACING); + uint160 sqrtLower = TickMath.getSqrtPriceAtTick(tickLower); + uint160 sqrtUpper = TickMath.getSqrtPriceAtTick(tickUpper); + + // Derive the liquidity that the desired amounts buy at the (live or target) price. For a full-range + // position straddling the price, this is the binding minimum of the liquidity implied by each side, + // so the deposit consumes the requested amount of the binding side and <= requested of the other. + uint128 liquidity = + LiquidityAmounts.getLiquidityForAmounts(sqrtPriceX96, sqrtLower, sqrtUpper, amount0, amount1); + require(liquidity > 0, 'computed liquidity is zero'); + + vm.startBroadcast(); + address me = msg.sender; + + _logSetup(e, key, c0, c1, amount0, amount1, liquidity, sqrtPriceX96, alreadyInitialized, me); + + require(IERC20Min(c0).balanceOf(me) >= amount0, 'insufficient currency0 balance'); + require(IERC20Min(c1).balanceOf(me) >= amount1, 'insufficient currency1 balance'); + + if (!alreadyInitialized) { + IPositionManager(e.positionManager).initializePool(key, sqrtPriceX96); + console.log('Pool initialized at the USD-derived starting price (init happens once per pool).'); + } else { + console.log('Pool already initialized; topping up at the live price (price unchanged).'); + } + + _approvePermit2(c0, c1, e); + + uint128 liqBefore = IStateView(e.stateView).getLiquidity(poolId); + uint256 tokenId = IPositionManager(e.positionManager).nextTokenId(); + + // amount0Max/amount1Max = type(uint128).max: the explicitly-computed `liquidity` is the real cap, + // and the balanceOf checks above bound the spend. This avoids the getLiquidityForAmounts (round-down) + // vs pool (round-up) off-by-one that can revert with MaximumAmountExceeded. + bytes memory actions = abi.encodePacked(MINT_POSITION, SETTLE_PAIR); + bytes[] memory params = new bytes[](2); + params[0] = abi.encode( + key, tickLower, tickUpper, uint256(liquidity), type(uint128).max, type(uint128).max, me, bytes('') + ); + params[1] = abi.encode(key.currency0, key.currency1); + + IPositionManager(e.positionManager).modifyLiquidities(abi.encode(actions, params), block.timestamp + 3600); + console.log('Minted v4 position tokenId:', tokenId); + + _verifyPool(e.stateView, poolId, tokenId, liqBefore); + + console.log(''); + console.log('SUCCESS: v4 stable pool created/topped up with full-range liquidity'); + vm.stopBroadcast(); + } + + /// @notice sqrtPriceX96 for an equal-decimals pair from USD prices. price(token1/token0) = price0/price1. + function _sqrtPriceFromUsd(uint256 price0_8, uint256 price1_8) internal pure returns (uint160) { + // inner = (price0/price1) * 2^192 -> sqrt(inner) = sqrt(price) * 2^96 = sqrtPriceX96 + uint256 inner = FullMath.mulDiv(price0_8, uint256(1) << 192, price1_8); + uint256 sp = _sqrt(inner); + require(sp > TickMath.MIN_SQRT_PRICE && sp < TickMath.MAX_SQRT_PRICE, 'price out of range'); + return uint160(sp); + } + + /// @notice Floor integer square root (Babylonian method). + function _sqrt(uint256 x) internal pure returns (uint256) { + if (x == 0) return 0; + uint256 z = (x + 1) / 2; + uint256 y = x; + while (z < y) { + y = z; + z = (x / z + z) / 2; + } + return y; + } + + function _loadEnv() internal view returns (Env memory e) { + string memory path = string.concat('./deployments/json/', vm.toString(block.chainid), '.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'); + + 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'); + } + + function _approvePermit2(address c0, address c1, Env memory e) internal { + // Step 1: let Permit2 pull tokens from msg.sender. + IERC20Min(c0).approve(e.permit2, type(uint256).max); + IERC20Min(c1).approve(e.permit2, type(uint256).max); + // Step 2: give PositionManager spender allowance via Permit2. + IPermit2(e.permit2).approve(c0, e.positionManager, type(uint160).max, type(uint48).max); + IPermit2(e.permit2).approve(c1, e.positionManager, type(uint160).max, type(uint48).max); + console.log('Permit2 allowances granted to PositionManager for both tokens'); + } + + function _logSetup( + Env memory e, + PoolKey memory key, + address c0, + address c1, + uint256 amount0, + uint256 amount1, + uint128 liquidity, + uint160 sqrtPriceX96, + bool alreadyInitialized, + address me + ) internal view { + console.log('Chain:', block.chainid); + console.log('Sender:', me); + console.log('PoolManager:', e.poolManager); + console.log('PositionManager:', e.positionManager); + console.log('StateView:', e.stateView); + console.log('Permit2:', e.permit2); + console.log('pool already initialized:', alreadyInitialized); + console.log('currency0:', c0); + console.log(' symbol:', IERC20Min(c0).symbol()); + console.log(' amount0 to deposit (raw):', amount0); + console.log('currency1:', c1); + console.log(' symbol:', IERC20Min(c1).symbol()); + console.log(' amount1 to deposit (raw):', amount1); + console.log('fee:', key.fee); + console.log('sqrtPriceX96 (computed, no operator input):', sqrtPriceX96); + console.log('computed liquidity:', liquidity); + } + + function _verifyPool(address stateView, bytes32 poolId, uint256 tokenId, uint128 liqBefore) internal view { + (uint160 sqrtPriceX96, int24 tick,,) = IStateView(stateView).getSlot0(poolId); + uint128 liqAfter = IStateView(stateView).getLiquidity(poolId); + console.log('Pool id:'); + console.logBytes32(poolId); + console.log('Pool slot0 sqrtPriceX96:', sqrtPriceX96); + console.log('Pool tick:', tick); + console.log('pool liquidity before:', liqBefore); + console.log('pool liquidity after:', liqAfter); + require(liqAfter > liqBefore, 'liquidity did not increase'); + require(tokenId > 0, 'no position minted'); + } +}