358 lines
14 KiB
Solidity
358 lines
14 KiB
Solidity
// 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));
|
|
}
|
|
}
|