// 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)); } }