From 896fffb2e84e4d22808b27b6b2e206f08d56e6d9 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 27 Feb 2026 06:12:03 +0000 Subject: [PATCH] fix: Backtesting #3: Replay historical Swap/Mint/Burn events against shadow pool (#317) Co-Authored-By: Claude Sonnet 4.6 --- .../script/backtesting/BacktestRunner.s.sol | 21 +- onchain/script/backtesting/EventReplayer.sol | 358 ++++++++++++++++++ 2 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 onchain/script/backtesting/EventReplayer.sol diff --git a/onchain/script/backtesting/BacktestRunner.s.sol b/onchain/script/backtesting/BacktestRunner.s.sol index 80ad2c8..1674beb 100644 --- a/onchain/script/backtesting/BacktestRunner.s.sol +++ b/onchain/script/backtesting/BacktestRunner.s.sol @@ -5,11 +5,13 @@ import { Script } from "forge-std/Script.sol"; import { console2 } from "forge-std/console2.sol"; import { MockToken } from "./MockToken.sol"; import { ShadowPool, ShadowPoolDeployer } from "./ShadowPoolDeployer.sol"; +import { EventReplayer } from "./EventReplayer.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. + * AERO/WETH 1% pool configuration, initialised at the price from the event cache, + * then replays all Swap/Mint/Burn events from the cache against the shadow pool. * * Usage: * forge script script/backtesting/BacktestRunner.s.sol \ @@ -86,6 +88,9 @@ contract BacktestRunner is Script { // Deploy factory + pool and initialise at the resolved price. ShadowPool memory sp = ShadowPoolDeployer.deploy(address(tokenA), address(tokenB), sqrtPriceX96); + // Deploy the event replayer with the canonical token ordering from the pool. + EventReplayer replayer = new EventReplayer(sp.pool, MockToken(sp.token0), MockToken(sp.token1)); + vm.stopBroadcast(); // Query pool state (view calls, no broadcast needed). @@ -98,10 +103,24 @@ contract BacktestRunner is Script { console2.log("Pool: ", address(sp.pool)); console2.log("Token0: ", sp.token0); console2.log("Token1: ", sp.token1); + console2.log("Replayer: ", address(replayer)); 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)); + + // ----------------------------------------------------------------------- + // Event replay (runs as local simulation — no broadcast required). + // Each pool.mint / pool.swap / pool.burn call executes against the shadow + // pool; vm.roll + vm.warp advance block state to match historical timing. + // ----------------------------------------------------------------------- + try vm.envString("BACKTEST_EVENTS_FILE") returns (string memory eventsFile) { + if (bytes(eventsFile).length > 0) { + console2.log("\n=== Starting Event Replay ==="); + console2.log("Events file: ", eventsFile); + replayer.replay(eventsFile); + } + } catch {} } } diff --git a/onchain/script/backtesting/EventReplayer.sol b/onchain/script/backtesting/EventReplayer.sol new file mode 100644 index 0000000..22a69c0 --- /dev/null +++ b/onchain/script/backtesting/EventReplayer.sol @@ -0,0 +1,358 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +import { Vm } from "forge-std/Vm.sol"; +import { console2 } from "forge-std/console2.sol"; +import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; +import { IUniswapV3MintCallback } from "@uniswap-v3-core/interfaces/callback/IUniswapV3MintCallback.sol"; +import { IUniswapV3SwapCallback } from "@uniswap-v3-core/interfaces/callback/IUniswapV3SwapCallback.sol"; +import { MockToken } from "./MockToken.sol"; +import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol"; + +/** + * @title EventReplayer + * @notice Replays historical Swap/Mint/Burn events from a JSON Lines cache + * against a shadow Uniswap V3 pool. + * + * Architecture note: + * EventReplayer is the owner of all shadow-pool positions because + * IUniswapV3Pool.mint() and pool.swap() call back to msg.sender to collect + * tokens. vm.prank() cannot be combined with pool callbacks (the pranked + * address has no code). Historical owner/sender addresses are therefore + * ignored for position accounting; only tick ranges and amounts matter. + * + * fetch-events.ts serialises BigInt Solidity values (int256, uint160, int24, + * uint128, …) as **decimal strings** in JSON. Use vm.parseJsonString() + + * vm.parseInt()/vm.parseUint() for those fields. The `block` and `logIndex` + * fields are JS numbers and may be read directly with vm.parseJsonUint(). + */ +contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { + // ------------------------------------------------------------------------- + // Constants + // ------------------------------------------------------------------------- + + /// @dev Foundry cheatcode address (same constant as in Vm.sol). + Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + /// @dev Log progress and check drift every N events. + uint256 internal constant LOG_INTERVAL = 100; + + /// @dev Approximate seconds per block on Base mainnet (~2 s). + uint256 internal constant BLOCK_DURATION = 2; + + // ------------------------------------------------------------------------- + // Immutables + // ------------------------------------------------------------------------- + + IUniswapV3Pool public immutable pool; + MockToken public immutable token0; + MockToken public immutable token1; + + // ------------------------------------------------------------------------- + // Stats (accumulated across the replay) + // ------------------------------------------------------------------------- + + uint256 public driftCount; + uint256 public totalAbsDrift; + uint256 public skippedCount; + + // ------------------------------------------------------------------------- + // Internal chain-state tracking + // ------------------------------------------------------------------------- + + uint256 internal _lastBlock; + uint256 internal _lastTimestamp; + + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + constructor(IUniswapV3Pool _pool, MockToken _token0, MockToken _token1) { + pool = _pool; + token0 = _token0; + token1 = _token1; + _lastBlock = block.number; + _lastTimestamp = block.timestamp; + } + + // ------------------------------------------------------------------------- + // Public entry point + // ------------------------------------------------------------------------- + + /** + * @notice Read every line of the JSON Lines events file and replay it against + * the shadow pool. + * @param eventsFile Absolute (or project-relative) path to the .jsonl cache. + */ + function replay(string memory eventsFile) external { + uint256 idx = 0; + + while (true) { + string memory line; + try vm.readLine(eventsFile) returns (string memory l) { + line = l; + } catch { + break; // EOF signals end of file + } + if (bytes(line).length == 0) break; // empty line also signals EOF + + _processEvent(line, idx); + idx++; + + // Progress log every LOG_INTERVAL events. + if (idx % LOG_INTERVAL == 0) { + (uint160 sp, int24 tick,,,,,) = pool.slot0(); + console2.log( + string.concat( + "[", + vm.toString(idx), + "] tick=", + vm.toString(int256(tick)), + " sqrtPriceX96=", + vm.toString(uint256(sp)) + ) + ); + } + } + + _logSummary(idx); + } + + // ------------------------------------------------------------------------- + // Internal: event dispatch + // ------------------------------------------------------------------------- + + function _processEvent(string memory line, uint256 idx) internal { + string memory eventName = vm.parseJsonString(line, ".event"); + uint256 blockNum = vm.parseJsonUint(line, ".block"); + + _advanceChain(blockNum); + + if (_streq(eventName, "Swap")) { + _replaySwap(line, idx); + } else if (_streq(eventName, "Mint")) { + _replayMint(line, idx); + } else if (_streq(eventName, "Burn")) { + _replayBurn(line, idx); + } + } + + // ------------------------------------------------------------------------- + // Swap + // ------------------------------------------------------------------------- + + function _replaySwap(string memory line, uint256 idx) internal { + // BigInt fields are serialised as decimal strings by fetch-events.ts. + int256 amount0 = vm.parseInt(vm.parseJsonString(line, ".amount0")); + int256 amount1 = vm.parseInt(vm.parseJsonString(line, ".amount1")); + uint160 targetSqrtPrice = uint160(vm.parseUint(vm.parseJsonString(line, ".sqrtPriceX96"))); + int24 expectedTick = int24(int256(vm.parseInt(vm.parseJsonString(line, ".tick")))); + + // Skip degenerate zero-amount events. + if (amount0 == 0 && amount1 == 0) { + skippedCount++; + return; + } + + // amount0 > 0 → caller paid token0 (zeroForOne = true). + // amount1 > 0 → caller paid token1 (zeroForOne = false). + bool zeroForOne = amount0 > 0; + + // Use the cached post-swap sqrtPrice as the price limit so the shadow + // pool stops at exactly the historical price regardless of liquidity shape. + uint160 minLimit = TickMath.MIN_SQRT_RATIO + 1; + uint160 maxLimit = TickMath.MAX_SQRT_RATIO - 1; + uint160 sqrtPriceLimitX96 = targetSqrtPrice; + if (zeroForOne && sqrtPriceLimitX96 <= minLimit) sqrtPriceLimitX96 = minLimit; + if (!zeroForOne && sqrtPriceLimitX96 >= maxLimit) sqrtPriceLimitX96 = maxLimit; + + // Skip if the pool is already at or past the target price. + (uint160 currentSqrtPrice,,,,,,) = pool.slot0(); + if (zeroForOne && currentSqrtPrice <= sqrtPriceLimitX96) { + skippedCount++; + return; + } + if (!zeroForOne && currentSqrtPrice >= sqrtPriceLimitX96) { + skippedCount++; + return; + } + + // amountSpecified: use the historical input amount (exact-input mode). + // The price limit is the binding constraint; extra tokens are returned. + int256 amountSpecified = zeroForOne ? amount0 : amount1; + + // Pre-fund this contract so the swap callback can pay the pool. + uint256 fundAmount = uint256(amountSpecified > 0 ? amountSpecified : -amountSpecified) + 1; + if (zeroForOne) { + token0.mint(address(this), fundAmount); + } else { + token1.mint(address(this), fundAmount); + } + + try pool.swap(address(this), zeroForOne, amountSpecified, sqrtPriceLimitX96, abi.encode(zeroForOne)) { + (, int24 newTick,,,,,) = pool.slot0(); + int256 drift = int256(newTick) - int256(expectedTick); + if (drift < 0) drift = -drift; + if (drift > 0) { + driftCount++; + totalAbsDrift += uint256(drift); + } + // Per-100-event progress line showing tick drift. + if ((idx + 1) % LOG_INTERVAL == 0) { + console2.log( + string.concat( + "[", + vm.toString(idx + 1), + "] tick=", + vm.toString(int256(newTick)), + " expected=", + vm.toString(int256(expectedTick)), + " drift=", + vm.toString(drift) + ) + ); + } + } catch { + skippedCount++; + } + } + + // ------------------------------------------------------------------------- + // Mint + // ------------------------------------------------------------------------- + + function _replayMint(string memory line, uint256 idx) internal { + uint128 amount = uint128(vm.parseUint(vm.parseJsonString(line, ".amount"))); + int24 tickLower = int24(int256(vm.parseInt(vm.parseJsonString(line, ".tickLower")))); + int24 tickUpper = int24(int256(vm.parseInt(vm.parseJsonString(line, ".tickUpper")))); + + // Skip zero-liquidity and degenerate tick ranges. + if (amount == 0) { + skippedCount++; + return; + } + if (tickLower >= tickUpper) { + skippedCount++; + return; + } + + // Skip ticks outside Uniswap's hard limits. + if (tickLower < TickMath.MIN_TICK || tickUpper > TickMath.MAX_TICK) { + skippedCount++; + return; + } + + // Skip ticks not aligned to the pool's tick spacing (out-of-range guard). + int24 tickSpacing = pool.tickSpacing(); + if (tickLower % tickSpacing != 0 || tickUpper % tickSpacing != 0) { + skippedCount++; + return; + } + + // Pre-fund so the mint callback can transfer both tokens to the pool. + token0.mint(address(this), 1_000_000 ether); + token1.mint(address(this), 1_000_000 ether); + + try pool.mint(address(this), tickLower, tickUpper, amount, "") { + // success — position recorded under address(this) + } catch { + skippedCount++; + } + } + + // ------------------------------------------------------------------------- + // Burn + // ------------------------------------------------------------------------- + + function _replayBurn(string memory line, uint256 idx) internal { + uint128 amount = uint128(vm.parseUint(vm.parseJsonString(line, ".amount"))); + int24 tickLower = int24(int256(vm.parseInt(vm.parseJsonString(line, ".tickLower")))); + int24 tickUpper = int24(int256(vm.parseInt(vm.parseJsonString(line, ".tickUpper")))); + + if (amount == 0) { + skippedCount++; + return; + } + + // Look up the shadow position owned by this contract. + bytes32 posKey = keccak256(abi.encodePacked(address(this), tickLower, tickUpper)); + (uint128 posLiquidity,,,,) = pool.positions(posKey); + + // No shadow position to burn (historical owner had liquidity we never minted). + if (posLiquidity == 0) { + skippedCount++; + return; + } + + // Clamp to available shadow liquidity (partial-burn edge case). + if (amount > posLiquidity) amount = posLiquidity; + + try pool.burn(tickLower, tickUpper, amount) { + // success + } catch { + skippedCount++; + } + } + + // ------------------------------------------------------------------------- + // Uniswap V3 callbacks + // ------------------------------------------------------------------------- + + /// @inheritdoc IUniswapV3MintCallback + function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external override { + require(msg.sender == address(pool), "EventReplayer: bad mint callback"); + if (amount0Owed > 0) token0.transfer(msg.sender, amount0Owed); + if (amount1Owed > 0) token1.transfer(msg.sender, amount1Owed); + } + + /// @inheritdoc IUniswapV3SwapCallback + function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external override { + require(msg.sender == address(pool), "EventReplayer: bad swap callback"); + bool zeroForOne = abi.decode(data, (bool)); + // Pay the positive delta (the input token the pool is owed). + if (zeroForOne && amount0Delta > 0) { + token0.transfer(msg.sender, uint256(amount0Delta)); + } else if (!zeroForOne && amount1Delta > 0) { + token1.transfer(msg.sender, uint256(amount1Delta)); + } + } + + // ------------------------------------------------------------------------- + // Chain-state helpers + // ------------------------------------------------------------------------- + + /** + * @notice Advance block number and timestamp to match the historical event's + * block. Only moves forward; events in the same block are no-ops. + */ + function _advanceChain(uint256 blockNum) internal { + if (blockNum <= _lastBlock) return; + uint256 delta = blockNum - _lastBlock; + _lastTimestamp += delta * BLOCK_DURATION; + _lastBlock = blockNum; + vm.roll(blockNum); + vm.warp(_lastTimestamp); + } + + // ------------------------------------------------------------------------- + // Utilities + // ------------------------------------------------------------------------- + + function _streq(string memory a, string memory b) internal pure returns (bool) { + return keccak256(bytes(a)) == keccak256(bytes(b)); + } + + function _logSummary(uint256 totalEvents) internal view { + (uint160 finalSp, int24 finalTick,,,,,) = pool.slot0(); + console2.log("=== Replay Complete ==="); + console2.log("Total events: ", totalEvents); + console2.log("Skipped: ", skippedCount); + console2.log("Drift events: ", driftCount); + console2.log("Total abs drift:", totalAbsDrift); + if (driftCount > 0) { + console2.log("Avg tick drift:", totalAbsDrift / driftCount); + } + console2.log("Final tick: ", int256(finalTick)); + console2.log("Final sqrtP: ", uint256(finalSp)); + } +}