diff --git a/onchain/script/backtesting/BacktestRunner.s.sol b/onchain/script/backtesting/BacktestRunner.s.sol index 80ad2c8..7b48ee1 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 \ @@ -88,6 +90,11 @@ contract BacktestRunner is Script { vm.stopBroadcast(); + // Instantiate EventReplayer outside the broadcast block: it uses Foundry cheat codes + // (vm.readLine, vm.roll, vm.warp) that only work in the forge simulation context and + // must not be sent as real transactions to the RPC endpoint. + EventReplayer replayer = new EventReplayer(sp.pool, MockToken(sp.token0), MockToken(sp.token1)); + // Query pool state (view calls, no broadcast needed). (uint160 slot0SqrtPrice, int24 tick,,,,,) = sp.pool.slot0(); uint128 liquidity = sp.pool.liquidity(); @@ -98,10 +105,40 @@ 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) { + // Reset file position — _resolveSqrtPrice() may have consumed line 1. + try vm.closeFile(eventsFile) {} catch {} + + // Pre-count events so replay() can show "[N/total]" progress lines. + uint256 totalEvents = 0; + { + string memory l = vm.readLine(eventsFile); + while (bytes(l).length > 0) { + totalEvents++; + l = vm.readLine(eventsFile); + } + } + // Reset before the replay pass; we know the file is open after the count loop. + vm.closeFile(eventsFile); + + console2.log("\n=== Starting Event Replay ==="); + console2.log("Events file: ", eventsFile); + console2.log("Total events: ", totalEvents); + replayer.replay(eventsFile, totalEvents); + } + } catch {} } } diff --git a/onchain/script/backtesting/EventReplayer.sol b/onchain/script/backtesting/EventReplayer.sol new file mode 100644 index 0000000..ecb36e8 --- /dev/null +++ b/onchain/script/backtesting/EventReplayer.sol @@ -0,0 +1,430 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +import { IUniswapV3MintCallback } from "@uniswap-v3-core/interfaces/callback/IUniswapV3MintCallback.sol"; +import { IUniswapV3SwapCallback } from "@uniswap-v3-core/interfaces/callback/IUniswapV3SwapCallback.sol"; +import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; +import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol"; +import { Vm } from "forge-std/Vm.sol"; +import { console2 } from "forge-std/console2.sol"; +import { MockToken } from "./MockToken.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; + /// @dev Pool tick spacing — cached once at construction (immutable for the pool lifetime). + int24 public immutable tickSpacing; + + // ------------------------------------------------------------------------- + // Stats (accumulated across the replay) + // ------------------------------------------------------------------------- + + /// @dev Count of LOG_INTERVAL checkpoints (and the final sample) where tick drift > 0. + uint256 public checkpointsWithDrift; + uint256 public totalAbsDrift; + uint256 public maxDrift; + 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; + tickSpacing = _pool.tickSpacing(); + _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. + * @param totalEvents Total number of events in the file (for progress display). + * Pass 0 to omit the denominator from progress logs. + */ + function replay(string memory eventsFile, uint256 totalEvents) external { + uint256 idx = 0; + + // Track the last Swap event's expected state for drift measurement. + int24 lastExpectedTick; + uint160 lastExpectedSqrtPrice; + bool hasSwapRef; + + while (true) { + string memory line; + try vm.readLine(eventsFile) returns (string memory l) { + line = l; + } catch { + break; // EOF or read error — done + } + if (bytes(line).length == 0) break; // empty line signals EOF + + string memory eventName = vm.parseJsonString(line, ".event"); + uint256 blockNum = vm.parseJsonUint(line, ".block"); + + _advanceChain(blockNum); + + if (_streq(eventName, "Swap")) { + (int24 expTick, uint160 expSqrtPrice) = _replaySwap(line); + // Update reference state only when the swap was not skipped. + if (expSqrtPrice != 0) { + lastExpectedTick = expTick; + lastExpectedSqrtPrice = expSqrtPrice; + hasSwapRef = true; + } + } else if (_streq(eventName, "Mint")) { + _replayMint(line); + } else if (_streq(eventName, "Burn")) { + _replayBurn(line); + } + + idx++; + + // Progress + drift validation every LOG_INTERVAL events. + if (idx % LOG_INTERVAL == 0 && hasSwapRef) { + _logCheckpoint(idx, totalEvents, lastExpectedTick, lastExpectedSqrtPrice); + } + } + + // Fetch final pool state once — used both for the trailing drift sample and _logSummary. + (uint160 finalSqrtPrice, int24 finalTick,,,,,) = pool.slot0(); + + // Final drift sample: captures trailing events after the last checkpoint. + // Guard: when idx is an exact multiple of LOG_INTERVAL, _logCheckpoint already fired for + // this identical pool state inside the loop — accumulating stats again would double-count + // that measurement in totalAbsDrift and checkpointsWithDrift. + if (hasSwapRef && idx % LOG_INTERVAL != 0) { + int256 diff = int256(finalTick) - int256(lastExpectedTick); + uint256 absDrift = diff >= 0 ? uint256(diff) : uint256(-diff); + totalAbsDrift += absDrift; + if (absDrift > 0) checkpointsWithDrift++; + if (absDrift > maxDrift) maxDrift = absDrift; + // Log sqrtPrice deviation when it exceeds ~0.01% (filters rounding noise). + if (finalSqrtPrice != lastExpectedSqrtPrice) { + uint256 priceDelta = finalSqrtPrice > lastExpectedSqrtPrice + ? uint256(finalSqrtPrice - lastExpectedSqrtPrice) + : uint256(lastExpectedSqrtPrice - finalSqrtPrice); + if (lastExpectedSqrtPrice > 0 && priceDelta * 10_000 > uint256(lastExpectedSqrtPrice)) { + console2.log(" final sqrtPrice divergence:", priceDelta); + } + } + } + + _logSummary(idx, finalSqrtPrice, finalTick); + } + + // ------------------------------------------------------------------------- + // Swap + // ------------------------------------------------------------------------- + + /** + * @notice Replay a Swap event. + * @return expectedTick Cached tick from the event (0 if skipped). + * @return expectedSqrtPrice Cached sqrtPriceX96 from the event (0 if skipped). + */ + function _replaySwap(string memory line) internal returns (int24 expectedTick, uint160 expectedSqrtPrice) { + // 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 evtTick = int24(int256(vm.parseInt(vm.parseJsonString(line, ".tick")))); + + // Skip degenerate zero-amount events. + if (amount0 == 0 && amount1 == 0) { + skippedCount++; + return (0, 0); + } + + // amount0 > 0 → caller paid token0 into pool (zeroForOne = true). + // amount1 > 0 → caller paid token1 into pool (zeroForOne = false). + bool zeroForOne = amount0 > 0; + + // amountSpecified: exact-input using the historical token amount. + // Guard against degenerate events where the expected input side is non-positive, + // which would unintentionally switch pool.swap into exact-output mode. + int256 amountSpecified = zeroForOne ? amount0 : amount1; + if (amountSpecified <= 0) { + skippedCount++; + return (0, 0); + } + + // Skip if the pool is already at or past the ORIGINAL target price. + // Perform this check against targetSqrtPrice — before any clamping — so that + // events targeting a price exactly at a hard limit are not incorrectly skipped. + (uint160 currentSqrtPrice,,,,,,) = pool.slot0(); + if (zeroForOne && currentSqrtPrice <= targetSqrtPrice) { + skippedCount++; + return (0, 0); + } + if (!zeroForOne && currentSqrtPrice >= targetSqrtPrice) { + skippedCount++; + return (0, 0); + } + + // Clamp the price LIMIT for pool.swap() to the exclusive valid range. + // Applied after the skip check so extreme-price events are not silently dropped; + // only the pool.swap parameter is adjusted. + uint160 sqrtPriceLimitX96 = targetSqrtPrice; + if (zeroForOne && sqrtPriceLimitX96 <= TickMath.MIN_SQRT_RATIO) { + sqrtPriceLimitX96 = TickMath.MIN_SQRT_RATIO + 1; + } + if (!zeroForOne && sqrtPriceLimitX96 >= TickMath.MAX_SQRT_RATIO) { + sqrtPriceLimitX96 = TickMath.MAX_SQRT_RATIO - 1; + } + + // Pre-fund so the swap callback can pay the pool without needing to mint + // inside the callback (which avoids a re-entrant MockToken.mint call). + // amountSpecified > 0 is guaranteed by the guard above, so the cast is safe. + uint256 fundAmount = uint256(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)) { + // success + } catch { + skippedCount++; + return (0, 0); + } + + return (evtTick, targetSqrtPrice); + } + + // ------------------------------------------------------------------------- + // Mint + // ------------------------------------------------------------------------- + + function _replayMint(string memory line) 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 || 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 (uses cached immutable). + if (tickLower % tickSpacing != 0 || tickUpper % tickSpacing != 0) { + skippedCount++; + return; + } + + // The mint callback (uniswapV3MintCallback) will mint tokens as needed. + try pool.mint(address(this), tickLower, tickUpper, amount, "") { + // success — position recorded under address(this) + } catch { + skippedCount++; + } + } + + // ------------------------------------------------------------------------- + // Burn + // ------------------------------------------------------------------------- + + function _replayBurn(string memory line) 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 mint predates our event window). + 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"); + // Mint exactly what the pool needs and transfer it immediately. + if (amount0Owed > 0) { + token0.mint(address(this), amount0Owed); + token0.transfer(msg.sender, amount0Owed); + } + if (amount1Owed > 0) { + token1.mint(address(this), amount1Owed); + 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); + } + + // ------------------------------------------------------------------------- + // Logging helpers + // ------------------------------------------------------------------------- + + /** + * @notice Emit a progress line and accumulate drift statistics for one checkpoint. + */ + function _logCheckpoint( + uint256 idx, + uint256 totalEvents, + int24 expectedTick, + uint160 expectedSqrtPrice + ) + internal + { + (uint160 currentSqrtPrice, int24 currentTick,,,,,) = pool.slot0(); + int256 diff = int256(currentTick) - int256(expectedTick); + uint256 absDrift = diff >= 0 ? uint256(diff) : uint256(-diff); + + totalAbsDrift += absDrift; + if (absDrift > 0) checkpointsWithDrift++; + if (absDrift > maxDrift) maxDrift = absDrift; + + string memory denominator = totalEvents > 0 ? string.concat("/", vm.toString(totalEvents)) : ""; + console2.log( + string.concat( + "[", + vm.toString(idx), + denominator, + "] tick=", + vm.toString(int256(currentTick)), + " expected=", + vm.toString(int256(expectedTick)), + " drift=", + vm.toString(absDrift) + ) + ); + + // Log sqrtPrice deviation when it exceeds ~0.01% (filters rounding noise). + if (currentSqrtPrice != expectedSqrtPrice) { + uint256 priceDelta = currentSqrtPrice > expectedSqrtPrice + ? uint256(currentSqrtPrice - expectedSqrtPrice) + : uint256(expectedSqrtPrice - currentSqrtPrice); + if (expectedSqrtPrice > 0 && priceDelta * 10_000 > uint256(expectedSqrtPrice)) { + console2.log(" sqrtPrice divergence:", priceDelta); + } + } + } + + // ------------------------------------------------------------------------- + // Utilities + // ------------------------------------------------------------------------- + + function _streq(string memory a, string memory b) internal pure returns (bool) { + return keccak256(bytes(a)) == keccak256(bytes(b)); + } + + function _logSummary(uint256 totalReplayed, uint160 finalSp, int24 finalTick) internal view { + console2.log("=== Replay Complete ==="); + console2.log("Total events: ", totalReplayed); + console2.log("Skipped: ", skippedCount); + console2.log("Drift checkpoints:", checkpointsWithDrift); + console2.log("Total abs drift: ", totalAbsDrift); + console2.log("Max drift: ", maxDrift); + if (checkpointsWithDrift > 0) { + console2.log("Avg tick drift: ", totalAbsDrift / checkpointsWithDrift); + } + console2.log("Final tick: ", int256(finalTick)); + console2.log("Final sqrtPriceX96:", uint256(finalSp)); + } +}