From 96b06bd9fecd7eb3a4a66c846f42092ac4cb4b69 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 27 Feb 2026 05:08:27 +0000 Subject: [PATCH] fix: Backtesting #2: Foundry script skeleton + Uniswap V3 shadow pool deployment (#316) Co-Authored-By: Claude Sonnet 4.6 --- .../script/backtesting/BacktestRunner.s.sol | 108 ++++++++++++++++++ onchain/script/backtesting/MockToken.sol | 24 ++++ .../script/backtesting/ShadowPoolDeployer.sol | 49 ++++++++ 3 files changed, 181 insertions(+) create mode 100644 onchain/script/backtesting/BacktestRunner.s.sol create mode 100644 onchain/script/backtesting/MockToken.sol create mode 100644 onchain/script/backtesting/ShadowPoolDeployer.sol diff --git a/onchain/script/backtesting/BacktestRunner.s.sol b/onchain/script/backtesting/BacktestRunner.s.sol new file mode 100644 index 0000000..f69bed9 --- /dev/null +++ b/onchain/script/backtesting/BacktestRunner.s.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +import { Script } from "forge-std/Script.sol"; +import { console2 } from "forge-std/console2.sol"; +import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; +import { MockToken } from "./MockToken.sol"; +import { ShadowPool, ShadowPoolDeployer } from "./ShadowPoolDeployer.sol"; + +/** + * @title BacktestRunner + * @notice Entry point for backtesting. Deploys a UniswapV3 shadow pool that mirrors the + * AERO/WETH 1% pool configuration, initialised at the price from the event cache. + * + * Usage: + * forge script script/backtesting/BacktestRunner.s.sol \ + * --rpc-url http://localhost:8545 --broadcast \ + * [BACKTEST_EVENTS_FILE=/path/to/events.jsonl] \ + * [INITIAL_SQRT_PRICE_X96=] + * + * Price resolution order (first that succeeds wins): + * 1. Environment variable INITIAL_SQRT_PRICE_X96 + * 2. sqrtPriceX96 field from the first line of $BACKTEST_EVENTS_FILE + * 3. Default: 2^96 (tick = 0, price 1:1) + */ +contract BacktestRunner is Script { + /// @dev 2^96 — corresponds to tick 0 (price ratio 1:1). + uint160 internal constant DEFAULT_SQRT_PRICE_X96 = 79_228_162_514_264_337_593_543_950_336; + + // ------------------------------------------------------------------------- + // Price resolution helpers + // ------------------------------------------------------------------------- + + /** + * @notice Parse sqrtPriceX96 from the first line of a JSON Lines events file. + * @dev Must be `external` so it can be called via `this.` inside a try/catch. + * fetch-events.ts serialises BigInt values as decimal strings, so we use + * parseJsonString then parseUint rather than parseJsonUint. + */ + function _parseSqrtPriceFromFile(string memory eventsFile) external returns (uint160) { + string memory line = vm.readLine(eventsFile); + string memory sqrtStr = vm.parseJsonString(line, ".sqrtPriceX96"); + return uint160(vm.parseUint(sqrtStr)); + } + + /** + * @notice Resolve the initial sqrtPriceX96 without broadcasting any transaction. + * @dev Call this BEFORE vm.startBroadcast() — the `this.` external call must not + * be included in the broadcast. + */ + function _resolveSqrtPrice() internal returns (uint160) { + // 1. Explicit env override. + try vm.envUint("INITIAL_SQRT_PRICE_X96") returns (uint256 val) { + if (val != 0) return uint160(val); + } catch {} + + // 2. First Swap event in the events cache. + try vm.envString("BACKTEST_EVENTS_FILE") returns (string memory eventsFile) { + try this._parseSqrtPriceFromFile(eventsFile) returns (uint160 val) { + if (val != 0) return val; + } catch {} + } catch {} + + // 3. Fallback default. + return DEFAULT_SQRT_PRICE_X96; + } + + // ------------------------------------------------------------------------- + // Main entry point + // ------------------------------------------------------------------------- + + function run() external { + // Resolve price before broadcast so the self-call is not recorded. + uint160 sqrtPriceX96 = _resolveSqrtPrice(); + + vm.startBroadcast(); + + // Deploy mock ERC20 tokens (18 decimals, matching AERO and WETH). + MockToken tokenA = new MockToken("Mock AERO", "mAERO", 18); + MockToken tokenB = new MockToken("Mock WETH", "mWETH", 18); + + // Mint an initial supply to the broadcaster for future liquidity seeding. + address sender = msg.sender; + tokenA.mint(sender, 1_000_000 ether); + tokenB.mint(sender, 1_000_000 ether); + + // Deploy factory + pool and initialise at the resolved price. + ShadowPool memory sp = ShadowPoolDeployer.deploy(address(tokenA), address(tokenB), sqrtPriceX96); + + vm.stopBroadcast(); + + // Query pool state (view calls, no broadcast needed). + (uint160 slot0SqrtPrice, int24 tick,,,,,) = sp.pool.slot0(); + uint128 liquidity = sp.pool.liquidity(); + int24 tickSpacing = sp.pool.tickSpacing(); + + console2.log("=== Backtesting Shadow Pool Deployment ==="); + console2.log("Factory: ", address(sp.factory)); + console2.log("Pool: ", address(sp.pool)); + console2.log("Token0: ", sp.token0); + console2.log("Token1: ", sp.token1); + console2.log("Fee tier: ", uint256(ShadowPoolDeployer.SHADOW_FEE)); + console2.log("Tick spacing: ", int256(tickSpacing)); + console2.log("sqrtPriceX96: ", uint256(slot0SqrtPrice)); + console2.log("Initial tick: ", int256(tick)); + console2.log("Liquidity: ", uint256(liquidity)); + } +} diff --git a/onchain/script/backtesting/MockToken.sol b/onchain/script/backtesting/MockToken.sol new file mode 100644 index 0000000..5c8d6f4 --- /dev/null +++ b/onchain/script/backtesting/MockToken.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +import { ERC20 } from "@openzeppelin/token/ERC20/ERC20.sol"; + +/** + * @title MockToken + * @notice Minimal ERC20 with open mint for backtesting only. No access control. + */ +contract MockToken is ERC20 { + uint8 private _dec; + + constructor(string memory name, string memory symbol, uint8 decimals_) ERC20(name, symbol) { + _dec = decimals_; + } + + function decimals() public view override returns (uint8) { + return _dec; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} diff --git a/onchain/script/backtesting/ShadowPoolDeployer.sol b/onchain/script/backtesting/ShadowPoolDeployer.sol new file mode 100644 index 0000000..ba5a739 --- /dev/null +++ b/onchain/script/backtesting/ShadowPoolDeployer.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +import { IUniswapV3Factory } from "@uniswap-v3-core/interfaces/IUniswapV3Factory.sol"; +import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; +import { UniswapHelpers } from "../../src/helpers/UniswapHelpers.sol"; + +struct ShadowPool { + IUniswapV3Factory factory; + IUniswapV3Pool pool; + address token0; + address token1; +} + +/** + * @title ShadowPoolDeployer + * @notice Deploys a fresh UniswapV3Factory and pool for backtesting. + * SHADOW_FEE = 10 000 (1%) matches the AERO/WETH pool on Base. + */ +library ShadowPoolDeployer { + uint24 internal constant SHADOW_FEE = 10_000; + + /** + * @notice Deploy a new UniswapV3 factory + pool and initialize it. + * @param tokenA One of the two mock tokens (any order). + * @param tokenB The other mock token. + * @param sqrtPriceX96 Initial sqrt price (Q64.96). + * @return sp ShadowPool struct with factory, pool, token0, token1 addresses. + */ + function deploy( + address tokenA, + address tokenB, + uint160 sqrtPriceX96 + ) + internal + returns (ShadowPool memory sp) + { + sp.factory = UniswapHelpers.deployUniswapFactory(); + + address poolAddr = sp.factory.createPool(tokenA, tokenB, SHADOW_FEE); + sp.pool = IUniswapV3Pool(poolAddr); + + sp.pool.initialize(sqrtPriceX96); + + // Resolve canonical token ordering from the pool. + sp.token0 = sp.pool.token0(); + sp.token1 = sp.pool.token1(); + } +}