diff --git a/onchain/script/backtesting/BacktestRunner.s.sol b/onchain/script/backtesting/BacktestRunner.s.sol index 48c8d23..7b48ee1 100644 --- a/onchain/script/backtesting/BacktestRunner.s.sol +++ b/onchain/script/backtesting/BacktestRunner.s.sol @@ -88,11 +88,13 @@ 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(); + // 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(); @@ -129,8 +131,8 @@ contract BacktestRunner is Script { l = vm.readLine(eventsFile); } } - // Reset again before the replay pass. - try vm.closeFile(eventsFile) {} catch {} + // 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); diff --git a/onchain/script/backtesting/EventReplayer.sol b/onchain/script/backtesting/EventReplayer.sol index 53e98ac..75d7b16 100644 --- a/onchain/script/backtesting/EventReplayer.sol +++ b/onchain/script/backtesting/EventReplayer.sol @@ -47,12 +47,15 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { 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) // ------------------------------------------------------------------------- - uint256 public driftCount; + /// @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; @@ -72,6 +75,7 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { pool = _pool; token0 = _token0; token1 = _token1; + tickSpacing = _pool.tickSpacing(); _lastBlock = block.number; _lastTimestamp = block.timestamp; } @@ -127,28 +131,27 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { // Progress + drift validation every LOG_INTERVAL events. if (idx % LOG_INTERVAL == 0 && hasSwapRef) { - (, int24 currentTick,,,,,) = pool.slot0(); - int256 diff = int256(currentTick) - int256(lastExpectedTick); - uint256 absDrift = diff >= 0 ? uint256(diff) : uint256(-diff); + _logCheckpoint(idx, totalEvents, lastExpectedTick, lastExpectedSqrtPrice); + } + } - totalAbsDrift += absDrift; - if (absDrift > 0) driftCount++; - 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(lastExpectedTick)), - " drift=", - vm.toString(absDrift) - ) - ); + // Final drift sample: capture any trailing events after the last checkpoint. + // This prevents understating drift when (totalReplayed % LOG_INTERVAL) != 0. + if (hasSwapRef) { + (uint160 finalSqrtPrice, int24 finalTick,,,,,) = pool.slot0(); + 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); + } } } @@ -181,8 +184,31 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { // amount1 > 0 → caller paid token1 into pool (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. + // 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; @@ -191,24 +217,10 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { sqrtPriceLimitX96 = TickMath.MAX_SQRT_RATIO - 1; } - // Skip if the pool is already at or past the target price. - (uint160 currentSqrtPrice,,,,,,) = pool.slot0(); - if (zeroForOne && currentSqrtPrice <= sqrtPriceLimitX96) { - skippedCount++; - return (0, 0); - } - if (!zeroForOne && currentSqrtPrice >= sqrtPriceLimitX96) { - skippedCount++; - return (0, 0); - } - - // amountSpecified: use the historical input amount (exact-input mode). - // The price limit is the binding constraint; the pool takes at most this amount. - int256 amountSpecified = zeroForOne ? amount0 : amount1; - // 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). - uint256 fundAmount = uint256(amountSpecified > 0 ? amountSpecified : -amountSpecified) + 1; + // 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 { @@ -246,8 +258,7 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { return; } - // Skip ticks not aligned to the pool's tick spacing (out-of-range guard). - int24 tickSpacing = pool.tickSpacing(); + // Skip ticks not aligned to the pool's tick spacing (uses cached immutable). if (tickLower % tickSpacing != 0 || tickUpper % tickSpacing != 0) { skippedCount++; return; @@ -342,6 +353,55 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { 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 // ------------------------------------------------------------------------- @@ -355,11 +415,11 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { console2.log("=== Replay Complete ==="); console2.log("Total events: ", totalReplayed); console2.log("Skipped: ", skippedCount); - console2.log("Drift checkpoints:", driftCount); + console2.log("Drift checkpoints:", checkpointsWithDrift); console2.log("Total abs drift: ", totalAbsDrift); console2.log("Max drift: ", maxDrift); - if (driftCount > 0) { - console2.log("Avg tick drift: ", totalAbsDrift / driftCount); + if (checkpointsWithDrift > 0) { + console2.log("Avg tick drift: ", totalAbsDrift / checkpointsWithDrift); } console2.log("Final tick: ", int256(finalTick)); console2.log("Final sqrtPriceX96:", uint256(finalSp));