From 896fffb2e84e4d22808b27b6b2e206f08d56e6d9 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 27 Feb 2026 06:12:03 +0000 Subject: [PATCH 1/6] 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)); + } +} From a3eb406e46d04123c6c5e2509b9b1597fc091de7 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 27 Feb 2026 06:17:54 +0000 Subject: [PATCH 2/6] 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 | 18 +- onchain/script/backtesting/EventReplayer.sol | 189 +++++++++--------- 2 files changed, 116 insertions(+), 91 deletions(-) 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)); } } From a884f8a5c9bf83b00c19d363c155bcf8bba1ae46 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 27 Feb 2026 06:27:58 +0000 Subject: [PATCH 3/6] ci: retrigger after infra failure From cd065275becba42aef48117636926ef8038ea004 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 27 Feb 2026 07:09:29 +0000 Subject: [PATCH 4/6] fix: address AI review feedback for #317 event replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache pool.tickSpacing() as immutable in EventReplayer constructor to avoid a repeated external call per _replayMint() invocation - Rename driftCount → driftCheckpoints for consistency with log label - Add sqrtDriftBps to the per-checkpoint progress log line, using the now-live lastExpectedSqrtPrice field (previously written but never read) - Guard _replaySwap(): skip and count events where amountSpecified ≤ 0, which would silently flip exact-input into exact-output mode - Add a final drift sample after the while-loop for trailing events not covered by the last LOG_INTERVAL checkpoint - Move EventReplayer construction outside the broadcast block in BacktestRunner (it uses vm.* cheat codes incompatible with real RPC) - Change second vm.closeFile() from try/catch to a direct call so errors surface rather than being silently swallowed Co-Authored-By: Claude Sonnet 4.6 --- .../script/backtesting/BacktestRunner.s.sol | 12 +- onchain/script/backtesting/EventReplayer.sol | 150 ++++++++++++------ 2 files changed, 112 insertions(+), 50 deletions(-) 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)); From ac32a1849cfdbfa225fdda62bf4166004c06d74f Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 27 Feb 2026 07:30:34 +0000 Subject: [PATCH 5/6] ci: retrigger after infra failure From 17b100ef2adf64378cf9d398dd5a5f0907862e0f Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 27 Feb 2026 07:55:53 +0000 Subject: [PATCH 6/6] fix: address AI review feedback (round 2) for #317 event replay - Guard final drift sample with `idx % LOG_INTERVAL != 0` to prevent double-counting stats when totalReplayed is an exact multiple of LOG_INTERVAL (the loop's _logCheckpoint already fired for that state) - Hoist pool.slot0() before the guard and pass finalSqrtPrice/finalTick to _logSummary(), eliminating the redundant slot0 read inside it Co-Authored-By: Claude Sonnet 4.6 --- onchain/script/backtesting/EventReplayer.sol | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/onchain/script/backtesting/EventReplayer.sol b/onchain/script/backtesting/EventReplayer.sol index 75d7b16..ecb36e8 100644 --- a/onchain/script/backtesting/EventReplayer.sol +++ b/onchain/script/backtesting/EventReplayer.sol @@ -135,10 +135,14 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { } } - // 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(); + // 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; @@ -155,7 +159,7 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { } } - _logSummary(idx); + _logSummary(idx, finalSqrtPrice, finalTick); } // ------------------------------------------------------------------------- @@ -410,8 +414,7 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { return keccak256(bytes(a)) == keccak256(bytes(b)); } - function _logSummary(uint256 totalReplayed) internal view { - (uint160 finalSp, int24 finalTick,,,,,) = pool.slot0(); + function _logSummary(uint256 totalReplayed, uint160 finalSp, int24 finalTick) internal view { console2.log("=== Replay Complete ==="); console2.log("Total events: ", totalReplayed); console2.log("Skipped: ", skippedCount);