diff --git a/onchain/script/backtesting/BacktestRunner.s.sol b/onchain/script/backtesting/BacktestRunner.s.sol index 1674beb..48c8d23 100644 --- a/onchain/script/backtesting/BacktestRunner.s.sol +++ b/onchain/script/backtesting/BacktestRunner.s.sol @@ -117,9 +117,25 @@ contract BacktestRunner is Script { // ----------------------------------------------------------------------- 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 again before the replay pass. + try vm.closeFile(eventsFile) {} catch {} + console2.log("\n=== Starting Event Replay ==="); console2.log("Events file: ", eventsFile); - replayer.replay(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 index 22a69c0..53e98ac 100644 --- a/onchain/script/backtesting/EventReplayer.sol +++ b/onchain/script/backtesting/EventReplayer.sol @@ -1,13 +1,13 @@ // 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 { 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 @@ -54,6 +54,7 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { uint256 public driftCount; uint256 public totalAbsDrift; + uint256 public maxDrift; uint256 public skippedCount; // ------------------------------------------------------------------------- @@ -83,33 +84,69 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { * @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) external { + 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 signals end of file + 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); } - 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(); + // 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); + + 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(tick)), - " sqrtPriceX96=", - vm.toString(uint256(sp)) + vm.toString(int256(currentTick)), + " expected=", + vm.toString(int256(lastExpectedTick)), + " drift=", + vm.toString(absDrift) ) ); } @@ -118,70 +155,59 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { _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 { + /** + * @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 expectedTick = int24(int256(vm.parseInt(vm.parseJsonString(line, ".tick")))); + int24 evtTick = int24(int256(vm.parseInt(vm.parseJsonString(line, ".tick")))); // Skip degenerate zero-amount events. if (amount0 == 0 && amount1 == 0) { skippedCount++; - return; + return (0, 0); } - // amount0 > 0 → caller paid token0 (zeroForOne = true). - // amount1 > 0 → caller paid token1 (zeroForOne = false). + // amount0 > 0 → caller paid token0 into pool (zeroForOne = true). + // 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. - 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; + 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; + } // Skip if the pool is already at or past the target price. (uint160 currentSqrtPrice,,,,,,) = pool.slot0(); if (zeroForOne && currentSqrtPrice <= sqrtPriceLimitX96) { skippedCount++; - return; + return (0, 0); } if (!zeroForOne && currentSqrtPrice >= sqrtPriceLimitX96) { skippedCount++; - return; + return (0, 0); } // amountSpecified: use the historical input amount (exact-input mode). - // The price limit is the binding constraint; extra tokens are returned. + // The price limit is the binding constraint; the pool takes at most this amount. int256 amountSpecified = zeroForOne ? amount0 : amount1; - // Pre-fund this contract so the swap callback can pay the pool. + // 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; if (zeroForOne) { token0.mint(address(this), fundAmount); @@ -190,48 +216,26 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { } 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) - ) - ); - } + // success } catch { skippedCount++; + return (0, 0); } + + return (evtTick, targetSqrtPrice); } // ------------------------------------------------------------------------- // Mint // ------------------------------------------------------------------------- - function _replayMint(string memory line, uint256 idx) internal { + 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) { - skippedCount++; - return; - } - if (tickLower >= tickUpper) { + if (amount == 0 || tickLower >= tickUpper) { skippedCount++; return; } @@ -249,10 +253,7 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { 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); - + // The mint callback (uniswapV3MintCallback) will mint tokens as needed. try pool.mint(address(this), tickLower, tickUpper, amount, "") { // success — position recorded under address(this) } catch { @@ -264,7 +265,7 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { // Burn // ------------------------------------------------------------------------- - function _replayBurn(string memory line, uint256 idx) internal { + 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")))); @@ -278,7 +279,7 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { 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). + // No shadow position to burn (historical mint predates our event window). if (posLiquidity == 0) { skippedCount++; return; @@ -301,8 +302,15 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { /// @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); + // 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 @@ -342,17 +350,18 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { return keccak256(bytes(a)) == keccak256(bytes(b)); } - function _logSummary(uint256 totalEvents) internal view { + function _logSummary(uint256 totalReplayed) 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); + console2.log("Total events: ", totalReplayed); + console2.log("Skipped: ", skippedCount); + console2.log("Drift checkpoints:", driftCount); + console2.log("Total abs drift: ", totalAbsDrift); + console2.log("Max drift: ", maxDrift); if (driftCount > 0) { - console2.log("Avg tick drift:", totalAbsDrift / driftCount); + console2.log("Avg tick drift: ", totalAbsDrift / driftCount); } - console2.log("Final tick: ", int256(finalTick)); - console2.log("Final sqrtP: ", uint256(finalSp)); + console2.log("Final tick: ", int256(finalTick)); + console2.log("Final sqrtPriceX96:", uint256(finalSp)); } }