From 2d1635ae2cb31bf46050536236108a5899601be0 Mon Sep 17 00:00:00 2001 From: RedWilly Date: Wed, 15 Jul 2026 09:33:31 +0000 Subject: [PATCH 1/2] feat: add support for Solidly stable and volatile pools in V2 protocol - Implemented `encodeV2RouteData` function to encode route data based on pool variant. - Enhanced V2 pool metadata discovery to include Solidly pool variants and their fees. - Updated V2 quote functions to handle Solidly stable swaps and introduced stable pool calculations. - Refactored event monitoring to reconcile addresses and manage hydration floor for logs. - Added tests for V2 metadata discovery, Solidly pool handling, and updated existing tests to include new variants. - Introduced new V3 pools and updated event handling to support new features. --- Contract/NArb.sol | 242 ++++++++++++++++------ Contract/UniswapFlashQuery.sol | 138 ++++++------ Contract/interfaces/IBaseV1Pair.sol | 15 +- Contract/interfaces/IERC20.sol | 71 +------ Contract/interfaces/IUniswapV2Pair.sol | 48 +---- Contract/interfaces/UniswapV2Factory.sol | 15 +- Contract/interfaces/Withdrawable.sol | 28 +-- scripts/bench-stress.ts | 2 +- src/constants.ts | 19 +- src/execute.ts | 51 ++--- src/market-db.ts | 40 +++- src/market-graph/market-graph.ts | 46 ++-- src/market-graph/types.ts | 5 +- src/opportunities/opportunity-engine.ts | 5 +- src/opportunities/opportunity-workflow.ts | 32 ++- src/protocols/carbon/runtime.ts | 32 ++- src/protocols/registry.ts | 6 +- src/protocols/v2/config.ts | 8 +- src/protocols/v2/execution.ts | 7 + src/protocols/v2/metadata.ts | 101 ++++++++- src/protocols/v2/quote.ts | 76 ++++++- src/protocols/v2/runtime.ts | 79 +++---- src/protocols/v2/types.ts | 14 ++ src/protocols/v3/config.ts | 18 ++ src/protocols/v3/runtime.ts | 22 +- src/runtime/arbitrage-bot.ts | 15 +- src/runtime/chain-cursor.ts | 6 - src/runtime/event-monitor.ts | 36 +++- src/runtime/protocol-event-adapter.ts | 5 +- test/carbon-mixed-strategy.test.ts | 6 + test/execution-lock.test.ts | 16 ++ test/market-db.test.ts | 3 + test/market-filter.test.ts | 6 + test/unified-graph.stress.test.ts | 3 + test/v2-metadata.test.ts | 72 +++++++ test/v2.stress.test.ts | 3 + test/v2.test.ts | 35 +++- test/v3-events.test.ts | 60 +++++- test/v3-strategy.test.ts | 3 + 39 files changed, 940 insertions(+), 449 deletions(-) create mode 100644 test/v2-metadata.test.ts diff --git a/Contract/NArb.sol b/Contract/NArb.sol index 1c9e384..d1707bf 100644 --- a/Contract/NArb.sol +++ b/Contract/NArb.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.0; import "./interfaces/Withdrawable.sol"; +import "./interfaces/IBaseV1Pair.sol"; import "./interfaces/IUniswapV2Pair.sol"; interface IUniswapV3Pool { @@ -53,6 +54,9 @@ error InvalidV3SwapDelta(); error InvalidFlashLoanCallback(); error InvalidCarbonAmount(); error CarbonApprovalFailed(); +error UnsupportedV2QuoteMode(); +error InvalidStablePair(); +error StableSolverDidNotConverge(); contract ArbitrageExecutor is Withdrawable { uint8 private constant V2 = 0; @@ -61,6 +65,7 @@ contract ArbitrageExecutor is Withdrawable { address private constant NATIVE_SEI = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; address private constant WSEI = 0xE30feDd158A2e3b13e9badaeABaFc5516e95e8C7; uint256 private constant FEE_DENOMINATOR = 10000; + uint256 private constant ONE = 1e18; uint160 private constant MIN_SQRT_RATIO_PLUS_ONE = 4295128740; uint160 private constant MAX_SQRT_RATIO_MINUS_ONE = 1461446703485210103287273052203988822378723970341; @@ -68,7 +73,6 @@ contract ArbitrageExecutor is Withdrawable { uint8 private pendingFlashProtocol; address private pendingFlashPool; address private pendingV3Pool; - address private pendingV3TokenIn; struct FlashData { address borrowedToken; @@ -93,6 +97,16 @@ contract ArbitrageExecutor is Withdrawable { bytes[] data; } + struct StablePairState { + uint256 scale0; + uint256 scale1; + uint256 reserve0; + uint256 reserve1; + bool stable; + address token0; + address token1; + } + constructor(address owner_) Withdrawable(owner_) {} function executeArbitrage(ArbParams calldata params) external { @@ -137,7 +151,7 @@ contract ArbitrageExecutor is Withdrawable { // ponytail: one generic fallback handles callback name variants instead of dozens of wrappers. fallback() external payable { - if (msg.sender == pendingV3Pool && pendingV3TokenIn != address(0)) { + if (msg.sender == pendingV3Pool && pendingV3Pool != address(0)) { (int256 amount0Delta, int256 amount1Delta, ) = abi.decode(msg.data[4:], (int256, int256, bytes)); _finishV3SwapCallback(amount0Delta, amount1Delta); @@ -168,7 +182,7 @@ contract ArbitrageExecutor is Withdrawable { _finishFlashLoan( loan, - borrowedAmount + ((borrowedAmount * loan.v2RepayFee) / (FEE_DENOMINATOR - loan.v2RepayFee)) + 1 + _v2RepayAmount(borrowedAmount, loan.v2RepayFee) ); } @@ -186,27 +200,19 @@ contract ArbitrageExecutor is Withdrawable { } function _finishV3SwapCallback(int256 amount0Delta, int256 amount1Delta) internal { - address tokenIn = pendingV3TokenIn; - if (msg.sender != pendingV3Pool || tokenIn == address(0)) revert InvalidV3SwapCallback(); + if (msg.sender != pendingV3Pool || pendingV3Pool == address(0)) revert InvalidV3SwapCallback(); if (amount0Delta > 0 && amount1Delta <= 0) { - _safeTransfer(tokenIn, msg.sender, uint256(amount0Delta)); + _safeTransfer(IUniswapV3Pool(msg.sender).token0(), msg.sender, uint256(amount0Delta)); } else if (amount1Delta > 0 && amount0Delta <= 0) { - _safeTransfer(tokenIn, msg.sender, uint256(amount1Delta)); + _safeTransfer(IUniswapV3Pool(msg.sender).token1(), msg.sender, uint256(amount1Delta)); } else { revert InvalidV3SwapDelta(); } } function _finishFlashLoan(FlashData memory loan, uint256 repayAmount) internal { - uint256 finalAmount = _executeCircularRoute( - loan.borrowedToken, - loan.borrowedAmount, - loan.pools, - loan.protocols, - loan.fees, - loan.data - ); + uint256 finalAmount = _executeCircularRoute(loan); if (finalAmount < repayAmount) revert InsufficientFlashLoanRepayment(); if (!IERC20(loan.borrowedToken).transfer(msg.sender, repayAmount)) { @@ -214,35 +220,29 @@ contract ArbitrageExecutor is Withdrawable { } } - function _executeCircularRoute( - address startToken, - uint256 startAmount, - address[] memory pools, - uint8[] memory protocols, - uint256[] memory fees, - bytes[] memory data - ) internal returns (uint256) { - address token = startToken; - uint256 amount = startAmount; - - for (uint256 i; i < pools.length; ) { - if (protocols[i] == V2) { + function _executeCircularRoute(FlashData memory loan) internal returns (uint256) { + address token = loan.borrowedToken; + uint256 amount = loan.borrowedAmount; + + for (uint256 i; i < loan.pools.length; ) { + if (loan.protocols[i] == V2) { bool forwardToNextV2 = - i + 1 < pools.length && - protocols[i + 1] == V2 && - pools[i + 1] != pools[i]; + i + 1 < loan.pools.length && + loan.protocols[i + 1] == V2 && + loan.pools[i + 1] != loan.pools[i]; (token, amount) = _swapV2( token, amount, - pools[i], - fees[i], - forwardToNextV2 ? pools[i + 1] : address(this), - i > 0 && protocols[i - 1] == V2 && pools[i - 1] != pools[i] + loan.pools[i], + loan.fees[i], + loan.data[i], + forwardToNextV2 ? loan.pools[i + 1] : address(this), + i > 0 && loan.protocols[i - 1] == V2 && loan.pools[i - 1] != loan.pools[i] ); - } else if (protocols[i] == V3) { - (token, amount) = _swapV3(token, amount, pools[i]); - } else if (protocols[i] == CARBON) { - (token, amount) = _swapCarbon(token, amount, pools[i], data[i]); + } else if (loan.protocols[i] == V3) { + (token, amount) = _swapV3(token, amount, loan.pools[i]); + } else if (loan.protocols[i] == CARBON) { + (token, amount) = _swapCarbon(token, amount, loan.pools[i], loan.data[i]); } else { revert UnsupportedProtocol(); } @@ -250,7 +250,7 @@ contract ArbitrageExecutor is Withdrawable { unchecked { ++i; } } - if (token != startToken) revert ArbitrageMustReturnToStart(); + if (token != loan.borrowedToken) revert ArbitrageMustReturnToStart(); return amount; } @@ -259,19 +259,20 @@ contract ArbitrageExecutor is Withdrawable { uint256 amountIn, address pairAddr, uint256 fee, + bytes memory quoteData, address recipient, bool inputAlreadySent ) internal returns (address tokenOut, uint256 amountOut) { IUniswapV2Pair pair = IUniswapV2Pair(pairAddr); bool zeroForOne; - (tokenOut, amountOut, zeroForOne) = _quoteV2(pair, tokenIn, amountIn, fee); + (tokenOut, amountOut, zeroForOne) = _quoteV2(pair, tokenIn, amountIn, fee, quoteData); if (!inputAlreadySent) _safeTransfer(tokenIn, pairAddr, amountIn); pair.swap( zeroForOne ? 0 : amountOut, zeroForOne ? amountOut : 0, recipient, - new bytes(0) + hex"" ); } @@ -279,8 +280,14 @@ contract ArbitrageExecutor is Withdrawable { IUniswapV2Pair pair, address tokenIn, uint256 amountIn, - uint256 fee + uint256 fee, + bytes memory quoteData ) internal view returns (address tokenOut, uint256 amountOut, bool zeroForOne) { + if (quoteData.length != 0) { + if (quoteData.length != 1 || quoteData[0] != 0x01) revert UnsupportedV2QuoteMode(); + return _quoteStableV2(address(pair), tokenIn, amountIn, fee); + } + address token0 = pair.token0(); address token1 = pair.token1(); zeroForOne = tokenIn == token0; @@ -296,6 +303,38 @@ contract ArbitrageExecutor is Withdrawable { tokenOut = zeroForOne ? token1 : token0; } + function _quoteStableV2( + address pair, + address tokenIn, + uint256 amountIn, + uint256 fee + ) internal view returns (address tokenOut, uint256 amountOut, bool zeroForOne) { + StablePairState memory state = _stablePairState(pair); + if (!state.stable) revert InvalidStablePair(); + + zeroForOne = tokenIn == state.token0; + if (!zeroForOne && tokenIn != state.token1) revert SwapPathError(); + if (state.reserve0 == 0 || state.reserve1 == 0 || state.scale0 == 0 || state.scale1 == 0) revert InvalidReserves(); + + amountOut = zeroForOne + ? _stableAmountOut(amountIn, state.reserve0, state.reserve1, state.scale0, state.scale1, fee) + : _stableAmountOut(amountIn, state.reserve1, state.reserve0, state.scale1, state.scale0, fee); + if (amountOut >= (zeroForOne ? state.reserve1 : state.reserve0)) revert OutputExceedsReserve(); + tokenOut = zeroForOne ? state.token1 : state.token0; + } + + function _stablePairState(address pair) private view returns (StablePairState memory state) { + ( + state.scale0, + state.scale1, + state.reserve0, + state.reserve1, + state.stable, + state.token0, + state.token1 + ) = IBaseV1Pair(pair).metadata(); + } + function _swapV3( address tokenIn, uint256 amountIn, @@ -308,22 +347,20 @@ contract ArbitrageExecutor is Withdrawable { if (!zeroForOne && tokenIn != token1) revert SwapPathError(); tokenOut = zeroForOne ? token1 : token0; - uint256 balanceBefore = IERC20(tokenOut).balanceOf(address(this)); pendingV3Pool = poolAddr; - pendingV3TokenIn = tokenIn; - pool.swap( + (int256 amount0, int256 amount1) = pool.swap( address(this), zeroForOne, int256(amountIn), zeroForOne ? MIN_SQRT_RATIO_PLUS_ONE : MAX_SQRT_RATIO_MINUS_ONE, - new bytes(0) + hex"" ); pendingV3Pool = address(0); - pendingV3TokenIn = address(0); - amountOut = IERC20(tokenOut).balanceOf(address(this)) - balanceBefore; - if (amountOut == 0) revert SwapPathError(); + int256 outputDelta = zeroForOne ? amount1 : amount0; + if (outputDelta >= 0) revert InvalidV3SwapDelta(); + amountOut = uint256(-outputDelta); } function _swapCarbon( @@ -336,25 +373,36 @@ contract ArbitrageExecutor is Withdrawable { address rawSourceToken; address rawTargetToken; - uint256[] memory strategyIds; - uint128[] memory amounts; + ICarbonController.TradeAction[] memory actions; if (data.length == 96) { uint256 strategyId; (strategyId, rawSourceToken, rawTargetToken) = abi.decode(data, (uint256, address, address)); - strategyIds = new uint256[](1); - amounts = new uint128[](1); - strategyIds[0] = strategyId; - amounts[0] = uint128(amountIn); + actions = new ICarbonController.TradeAction[](1); + actions[0] = ICarbonController.TradeAction({strategyId: strategyId, amount: uint128(amountIn)}); } else { + uint256[] memory strategyIds; + uint128[] memory amounts; (rawSourceToken, rawTargetToken, strategyIds, amounts) = abi.decode(data, (address, address, uint256[], uint128[])); + if (strategyIds.length == 0 || strategyIds.length != amounts.length) revert SwapPathError(); + + actions = new ICarbonController.TradeAction[](strategyIds.length); + uint256 totalActionAmount; + for (uint256 i; i < strategyIds.length; ) { + totalActionAmount += amounts[i]; + actions[i] = ICarbonController.TradeAction({ + strategyId: strategyIds[i], + amount: amounts[i] + }); + unchecked { ++i; } + } + if (totalActionAmount != amountIn) revert InvalidCarbonAmount(); } bool sourceIsNative = rawSourceToken == NATIVE_SEI; bool targetIsNative = rawTargetToken == NATIVE_SEI; tokenOut = targetIsNative ? WSEI : rawTargetToken; if (sourceIsNative && tokenIn != WSEI) revert SwapPathError(); if (!sourceIsNative && tokenIn != rawSourceToken) revert SwapPathError(); - if (strategyIds.length == 0 || strategyIds.length != amounts.length) revert SwapPathError(); if (sourceIsNative) { IWSEI(WSEI).withdraw(amountIn); @@ -366,18 +414,6 @@ contract ArbitrageExecutor is Withdrawable { ? address(this).balance : IERC20(rawTargetToken).balanceOf(address(this)); - ICarbonController.TradeAction[] memory actions = new ICarbonController.TradeAction[](strategyIds.length); - uint256 totalActionAmount; - for (uint256 i; i < strategyIds.length; ) { - totalActionAmount += amounts[i]; - actions[i] = ICarbonController.TradeAction({ - strategyId: strategyIds[i], - amount: amounts[i] - }); - unchecked { ++i; } - } - if (totalActionAmount != amountIn) revert InvalidCarbonAmount(); - ICarbonController(controller).tradeBySourceAmount{value: sourceIsNative ? amountIn : 0}( rawSourceToken, rawTargetToken, @@ -434,6 +470,74 @@ contract ArbitrageExecutor is Withdrawable { return (amountInWithFee * reserveOut) / ((reserveIn * FEE_DENOMINATOR) + amountInWithFee); } + function _v2RepayAmount(uint256 borrowedAmount, uint256 fee) internal pure returns (uint256) { + uint256 denominator = FEE_DENOMINATOR - fee; + return borrowedAmount + ((borrowedAmount * fee + denominator - 1) / denominator); + } + + function _stableAmountOut( + uint256 amountIn, + uint256 reserveIn, + uint256 reserveOut, + uint256 scaleIn, + uint256 scaleOut, + uint256 fee + ) internal pure returns (uint256) { + amountIn -= (amountIn * fee) / FEE_DENOMINATOR; + uint256 normalizedIn = (reserveIn * ONE) / scaleIn; + uint256 normalizedOut = (reserveOut * ONE) / scaleOut; + uint256 invariant = _stableK(normalizedIn, normalizedOut); + uint256 nextOut = _stableY( + normalizedIn + (amountIn * ONE) / scaleIn, + invariant, + normalizedOut + ); + return ((normalizedOut - nextOut) * scaleOut) / ONE; + } + + function _stableK(uint256 x, uint256 y) private pure returns (uint256) { + uint256 a = (x * y) / ONE; + uint256 b = ((x * x) / ONE) + ((y * y) / ONE); + return (a * b) / ONE; + } + + function _stableF(uint256 x, uint256 x3, uint256 y) private pure returns (uint256) { + return _stableF(x, x3, y, (y * y) / ONE); + } + + function _stableF(uint256 x, uint256 x3, uint256 y, uint256 y2) private pure returns (uint256) { + return (x * ((y2 * y) / ONE)) / ONE + (x3 * y) / ONE; + } + + function _stableY(uint256 x, uint256 invariant, uint256 y) private pure returns (uint256) { + uint256 x3 = ((((x * x) / ONE) * x) / ONE); + for (uint256 i; i < 255; ) { + uint256 y2 = (y * y) / ONE; + uint256 k = _stableF(x, x3, y, y2); + uint256 d = (3 * x * y2) / ONE + x3; + if (d == 0) revert InvalidReserves(); + + if (k < invariant) { + uint256 dy = ((invariant - k) * ONE) / d; + if (dy == 0) { + if (k == invariant) return y; + if (_stableF(x, x3, y + 1) > invariant) return y + 1; + dy = 1; + } + y += dy; + } else { + uint256 dy = ((k - invariant) * ONE) / d; + if (dy == 0) { + if (k == invariant || _stableF(x, x3, y - 1) < invariant) return y; + dy = 1; + } + y -= dy; + } + unchecked { ++i; } + } + revert StableSolverDidNotConverge(); + } + function _safeTransfer(address token, address to, uint256 amount) internal { if (!IERC20(token).transfer(to, amount)) revert TokenTransferFailed(); } diff --git a/Contract/UniswapFlashQuery.sol b/Contract/UniswapFlashQuery.sol index 4a82f7c..278e6e8 100644 --- a/Contract/UniswapFlashQuery.sol +++ b/Contract/UniswapFlashQuery.sol @@ -12,12 +12,7 @@ interface IUniswapV3Pool { view returns ( uint160 sqrtPriceX96, - int24 tick, - uint16 observationIndex, - uint16 observationCardinality, - uint16 observationCardinalityNext, - uint8 feeProtocol, - bool unlocked + int24 tick ); function ticks(int24 tick) external @@ -60,6 +55,10 @@ interface ICarbonController { function pairTradingFeePPM(address token0, address token1) external view returns (uint32); } +error ArrayLengthMismatch(); +error InvalidTickSpacing(); +error InvalidRange(); + // In order to quickly load up data from Uniswap-like market, this contract allows easy iteration with a single eth_call contract FlashUniswapQueryV1 { uint8 private constant V3_STARTUP_BITMAP_WORD_RADIUS = 2; @@ -106,16 +105,18 @@ contract FlashUniswapQueryV1 { function getReservesByPairs(IUniswapV2Pair[] calldata _pairs) external view returns (uint256[3][] memory) { uint256[3][] memory result = new uint256[3][](_pairs.length); - for (uint256 i = 0; i < _pairs.length; i++) { + for (uint256 i; i < _pairs.length; ) { (result[i][0], result[i][1], result[i][2]) = _pairs[i].getReserves(); + unchecked { ++i; } } return result; } function getV3LiveStates(IUniswapV3Pool[] calldata _pools) external view returns (V3LiveState[] memory) { V3LiveState[] memory result = new V3LiveState[](_pools.length); - for (uint256 i = 0; i < _pools.length; i++) { + for (uint256 i; i < _pools.length; ) { result[i] = _getV3LiveState(_pools[i]); + unchecked { ++i; } } return result; } @@ -124,19 +125,19 @@ contract FlashUniswapQueryV1 { IUniswapV3Pool[] calldata _pools, int24[] calldata _tickSpacings ) external view returns (V3StartupState[] memory) { - require(_pools.length == _tickSpacings.length, "Array length mismatch"); + if (_pools.length != _tickSpacings.length) revert ArrayLengthMismatch(); V3StartupState[] memory result = new V3StartupState[](_pools.length); - for (uint256 i = 0; i < _pools.length; i++) { + for (uint256 i; i < _pools.length; ) { V3LiveState memory live = _getV3LiveState(_pools[i]); - int16[] memory wordPositions = _getV3StartupWordPositions(live.tick, _tickSpacings[i]); - V3BitmapData[] memory bitmaps = _getV3TickBitmaps(_pools[i], wordPositions); + V3BitmapData[] memory bitmaps = _getV3TickBitmaps(_pools[i], live.tick, _tickSpacings[i]); result[i] = V3StartupState({ live: live, bitmaps: bitmaps, ticks: _getV3InitializedTicksFromBitmaps(_pools[i], _tickSpacings[i], bitmaps) }); + unchecked { ++i; } } return result; } @@ -146,7 +147,7 @@ contract FlashUniswapQueryV1 { CarbonPairRequest[] calldata _requests ) external view returns (CarbonPairStrategies[] memory) { CarbonPairStrategies[] memory result = new CarbonPairStrategies[](_requests.length); - for (uint256 i = 0; i < _requests.length; i++) { + for (uint256 i; i < _requests.length; ) { CarbonPairRequest calldata request = _requests[i]; result[i] = CarbonPairStrategies({ token0: request.token0, @@ -159,12 +160,13 @@ contract FlashUniswapQueryV1 { request.endIndex ) }); + unchecked { ++i; } } return result; } function _getV3LiveState(IUniswapV3Pool _pool) internal view returns (V3LiveState memory) { - (uint160 sqrtPriceX96, int24 tick, , , , , ) = _pool.slot0(); + (uint160 sqrtPriceX96, int24 tick) = _pool.slot0(); return V3LiveState({ pool: address(_pool), sqrtPriceX96: sqrtPriceX96, @@ -175,38 +177,25 @@ contract FlashUniswapQueryV1 { function _getV3TickBitmaps( IUniswapV3Pool _pool, - int16[] memory _wordPositions - ) internal view returns (V3BitmapData[] memory) { - V3BitmapData[] memory result = new V3BitmapData[](_wordPositions.length); - for (uint256 i = 0; i < _wordPositions.length; i++) { - result[i] = V3BitmapData({ - wordPosition: _wordPositions[i], - bitmap: _pool.tickBitmap(_wordPositions[i]) - }); - } - return result; - } - - function _getV3StartupWordPositions( int24 _tick, int24 _tickSpacing - ) internal pure returns (int16[] memory) { - require(_tickSpacing > 0, "Invalid tick spacing"); + ) internal view returns (V3BitmapData[] memory) { + if (_tickSpacing <= 0) revert InvalidTickSpacing(); int24 compressed = _tick / _tickSpacing; - if (_tick < 0 && _tick % _tickSpacing != 0) { - compressed--; - } + if (_tick < 0 && _tick % _tickSpacing != 0) compressed--; - int16 centerWord = int16(compressed >> 8); uint256 wordCount = uint256(V3_STARTUP_BITMAP_WORD_RADIUS) * 2 + 1; - int16[] memory result = new int16[](wordCount); - int16 startWord = centerWord - int16(uint16(V3_STARTUP_BITMAP_WORD_RADIUS)); - - for (uint256 i = 0; i < wordCount; i++) { - result[i] = startWord + int16(uint16(i)); + int16 startWord = int16(compressed >> 8) - int16(uint16(V3_STARTUP_BITMAP_WORD_RADIUS)); + V3BitmapData[] memory result = new V3BitmapData[](wordCount); + for (uint256 i; i < wordCount; ) { + int16 wordPosition = startWord + int16(uint16(i)); + result[i] = V3BitmapData({ + wordPosition: wordPosition, + bitmap: _pool.tickBitmap(wordPosition) + }); + unchecked { ++i; } } - return result; } @@ -215,44 +204,56 @@ contract FlashUniswapQueryV1 { int24 _tickSpacing, V3BitmapData[] memory _bitmaps ) internal view returns (V3TickData[] memory) { - require(_tickSpacing > 0, "Invalid tick spacing"); + if (_tickSpacing <= 0) revert InvalidTickSpacing(); - V3TickData[] memory temp = new V3TickData[](V3_STARTUP_MAX_INITIALIZED_TICKS_PER_POOL); + V3TickData[] memory result = new V3TickData[](_initializedTickCapacity(_bitmaps)); uint256 tickCount = 0; - for (uint256 wordIndex = 0; wordIndex < _bitmaps.length; wordIndex++) { + for (uint256 wordIndex; wordIndex < _bitmaps.length; ) { uint256 bitmap = _bitmaps[wordIndex].bitmap; - if (bitmap == 0) { - continue; - } - - for (uint16 bit = 0; bit < 256 && tickCount < V3_STARTUP_MAX_INITIALIZED_TICKS_PER_POOL; bit++) { - if ((bitmap & (uint256(1) << bit)) == 0) { - continue; + if (bitmap != 0) { + for (uint16 byteIndex; byteIndex < 32 && tickCount < result.length; ) { + uint8 chunk = uint8(bitmap >> (byteIndex * 8)); + if (chunk != 0) { + for (uint8 bitInByte; bitInByte < 8 && tickCount < result.length; ) { + if ((chunk & (uint8(1) << bitInByte)) != 0) { + uint256 bit = uint256(byteIndex) * 8 + bitInByte; + int24 tick = int24( + (int256(_bitmaps[wordIndex].wordPosition) * 256 + int256(bit)) * + int256(_tickSpacing) + ); + + result[tickCount] = _getV3Tick(_pool, tick); + unchecked { ++tickCount; } + } + unchecked { ++bitInByte; } + } + } + unchecked { ++byteIndex; } } - - int24 tick = int24( - (int256(_bitmaps[wordIndex].wordPosition) * 256 + int256(uint256(bit))) * - int256(_tickSpacing) - ); - - temp[tickCount] = _getV3Tick(_pool, tick); - tickCount++; } - if (tickCount >= V3_STARTUP_MAX_INITIALIZED_TICKS_PER_POOL) { + if (tickCount == result.length) { break; } - } - - V3TickData[] memory result = new V3TickData[](tickCount); - for (uint256 i = 0; i < tickCount; i++) { - result[i] = temp[i]; + unchecked { ++wordIndex; } } return result; } + function _initializedTickCapacity(V3BitmapData[] memory _bitmaps) private pure returns (uint256 count) { + for (uint256 i; i < _bitmaps.length; ) { + uint256 bitmap = _bitmaps[i].bitmap; + while (bitmap != 0 && count < V3_STARTUP_MAX_INITIALIZED_TICKS_PER_POOL) { + bitmap &= bitmap - 1; + unchecked { ++count; } + } + if (count == V3_STARTUP_MAX_INITIALIZED_TICKS_PER_POOL) return count; + unchecked { ++i; } + } + } + function _getV3Tick( IUniswapV3Pool _pool, int24 _tick @@ -285,30 +286,33 @@ contract FlashUniswapQueryV1 { if (_stop > _allPairsLength) { _stop = _allPairsLength; } - require(_stop >= _start, "start cannot be higher than stop"); + if (_stop < _start) revert InvalidRange(); uint256 _qty = _stop - _start; address[3][] memory result = new address[3][](_qty); - for (uint256 i = 0; i < _qty; i++) { + for (uint256 i; i < _qty; ) { IUniswapV2Pair _uniswapPair = IUniswapV2Pair(_uniswapFactory.allPairs(_start + i)); result[i][0] = _uniswapPair.token0(); result[i][1] = _uniswapPair.token1(); result[i][2] = address(_uniswapPair); + unchecked { ++i; } } return result; } function filterVolatileHermesPairs(IBaseV1Pair[] calldata _pairs) external view returns (bool[] memory) { bool[] memory result = new bool[](_pairs.length); - for (uint256 i = 0; i < _pairs.length; i++) { + for (uint256 i; i < _pairs.length; ) { (, , , , result[i], , ) = _pairs[i].metadata(); + unchecked { ++i; } } return result; } function getPairsLength(UniswapV2Factory[] calldata _factories) external view returns (uint256[] memory) { uint256[] memory result = new uint256[](_factories.length); - for (uint256 i = 0; i < _factories.length; i++) { + for (uint256 i; i < _factories.length; ) { result[i] = _factories[i].allPairsLength(); + unchecked { ++i; } } return result; } diff --git a/Contract/interfaces/IBaseV1Pair.sol b/Contract/interfaces/IBaseV1Pair.sol index 5ec28f3..5ddf1cf 100644 --- a/Contract/interfaces/IBaseV1Pair.sol +++ b/Contract/interfaces/IBaseV1Pair.sol @@ -14,17 +14,4 @@ interface IBaseV1Pair { address token0, address token1 ); - - function getReserves() - external - view - returns ( - uint112 reserve0, - uint112 reserve1, - uint32 blockTimestampLast - ); - - function token0() external view returns (address); - - function token1() external view returns (address); -} \ No newline at end of file +} diff --git a/Contract/interfaces/IERC20.sol b/Contract/interfaces/IERC20.sol index 9bc7cf9..9b43b15 100644 --- a/Contract/interfaces/IERC20.sol +++ b/Contract/interfaces/IERC20.sol @@ -1,78 +1,9 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v4.9.0) (token/ERC20/IERC20.sol) - pragma solidity ^0.8.0; -/** - * @dev Interface of the ERC20 standard as defined in the EIP. - */ interface IERC20 { - /** - * @dev Emitted when `value` tokens are moved from one account (`from`) to - * another (`to`). - * - * Note that `value` may be zero. - */ - event Transfer(address indexed from, address indexed to, uint256 value); - - /** - * @dev Emitted when the allowance of a `spender` for an `owner` is set by - * a call to {approve}. `value` is the new allowance. - */ - event Approval(address indexed owner, address indexed spender, uint256 value); - - /** - * @dev Returns the amount of tokens in existence. - */ - function totalSupply() external view returns (uint256); - - /** - * @dev Returns the amount of tokens owned by `account`. - */ function balanceOf(address account) external view returns (uint256); - - /** - * @dev Moves `amount` tokens from the caller's account to `to`. - * - * Returns a boolean value indicating whether the operation succeeded. - * - * Emits a {Transfer} event. - */ function transfer(address to, uint256 amount) external returns (bool); - - /** - * @dev Returns the remaining number of tokens that `spender` will be - * allowed to spend on behalf of `owner` through {transferFrom}. This is - * zero by default. - * - * This value changes when {approve} or {transferFrom} are called. - */ function allowance(address owner, address spender) external view returns (uint256); - - /** - * @dev Sets `amount` as the allowance of `spender` over the caller's tokens. - * - * Returns a boolean value indicating whether the operation succeeded. - * - * IMPORTANT: Beware that changing an allowance with this method brings the risk - * that someone may use both the old and the new allowance by unfortunate - * transaction ordering. One possible solution to mitigate this race - * condition is to first reduce the spender's allowance to 0 and set the - * desired value afterwards: - * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 - * - * Emits an {Approval} event. - */ function approve(address spender, uint256 amount) external returns (bool); - - /** - * @dev Moves `amount` tokens from `from` to `to` using the - * allowance mechanism. `amount` is then deducted from the caller's - * allowance. - * - * Returns a boolean value indicating whether the operation succeeded. - * - * Emits a {Transfer} event. - */ - function transferFrom(address from, address to, uint256 amount) external returns (bool); -} \ No newline at end of file +} diff --git a/Contract/interfaces/IUniswapV2Pair.sol b/Contract/interfaces/IUniswapV2Pair.sol index 913b328..b4c5de9 100644 --- a/Contract/interfaces/IUniswapV2Pair.sol +++ b/Contract/interfaces/IUniswapV2Pair.sol @@ -2,52 +2,8 @@ pragma solidity ^0.8.0; interface IUniswapV2Pair { - event Approval(address indexed owner, address indexed spender, uint value); - event Transfer(address indexed from, address indexed to, uint value); - - function name() external pure returns (string memory); - function symbol() external pure returns (string memory); - function decimals() external pure returns (uint8); - function totalSupply() external view returns (uint); - function balanceOf(address owner) external view returns (uint); - function allowance(address owner, address spender) external view returns (uint); - - function approve(address spender, uint value) external returns (bool); - function transfer(address to, uint value) external returns (bool); - function transferFrom(address from, address to, uint value) external returns (bool); - - function DOMAIN_SEPARATOR() external view returns (bytes32); - function PERMIT_TYPEHASH() external pure returns (bytes32); - function nonces(address owner) external view returns (uint); - - function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external; - - event Mint(address indexed sender, uint amount0, uint amount1); - event Burn(address indexed sender, uint amount0, uint amount1, address indexed to); - event Swap( - address indexed sender, - uint amount0In, - uint amount1In, - uint amount0Out, - uint amount1Out, - address indexed to - ); - event Sync(uint112 reserve0, uint112 reserve1); - - function MINIMUM_LIQUIDITY() external pure returns (uint); - function factory() external view returns (address); function token0() external view returns (address); function token1() external view returns (address); function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast); - function price0CumulativeLast() external view returns (uint); - function price1CumulativeLast() external view returns (uint); - function kLast() external view returns (uint); - - function mint(address to) external returns (uint liquidity); - function burn(address to) external returns (uint amount0, uint amount1); - function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external; - function skim(address to) external; - function sync() external; - - function initialize(address, address) external; -} \ No newline at end of file + function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external; +} diff --git a/Contract/interfaces/UniswapV2Factory.sol b/Contract/interfaces/UniswapV2Factory.sol index 25ae373..f850fb6 100644 --- a/Contract/interfaces/UniswapV2Factory.sol +++ b/Contract/interfaces/UniswapV2Factory.sol @@ -2,17 +2,6 @@ pragma solidity ^0.8.0; interface UniswapV2Factory { - event PairCreated(address indexed token0, address indexed token1, address pair, uint256); - - function feeTo() external view returns (address); - function feeToSetter() external view returns (address); - - function getPair(address tokenA, address tokenB) external view returns (address pair); - function allPairs(uint256) external view returns (address pair); + function allPairs(uint256 index) external view returns (address pair); function allPairsLength() external view returns (uint256); - - function createPair(address tokenA, address tokenB) external returns (address pair); - - function setFeeTo(address) external; - function setFeeToSetter(address) external; -} \ No newline at end of file +} diff --git a/Contract/interfaces/Withdrawable.sol b/Contract/interfaces/Withdrawable.sol index 6e93528..ed0e1cb 100644 --- a/Contract/interfaces/Withdrawable.sol +++ b/Contract/interfaces/Withdrawable.sol @@ -3,6 +3,10 @@ pragma solidity ^0.8.0; import "./IERC20.sol"; +error NotOwner(); +error InvalidOwner(); +error WithdrawalFailed(); + abstract contract Withdrawable { address public owner; @@ -11,42 +15,42 @@ abstract contract Withdrawable { } modifier onlyOwner() { - require(msg.sender == owner, "Only owner can call this function"); + if (msg.sender != owner) revert NotOwner(); _; } // Function to withdraw ERC20 tokens function withdrawToken(address tokenAddress, uint256 amount) external onlyOwner { - IERC20 token = IERC20(tokenAddress); - bool success = token.transfer(owner, amount); - require(success, "Token transfer failed"); + if (!IERC20(tokenAddress).transfer(owner, amount)) revert WithdrawalFailed(); } // Function to withdraw native tokens (e.g., ETH, METIS) function withdrawNative(uint256 amount) external onlyOwner { - payable(owner).transfer(amount); + _withdrawNative(amount); } // Function to withdraw all native tokens function withdrawAllNative() external onlyOwner { - uint256 balance = address(this).balance; - payable(owner).transfer(balance); + _withdrawNative(address(this).balance); } // Function to withdraw all of a specific ERC20 token function withdrawAllToken(address tokenAddress) external onlyOwner { IERC20 token = IERC20(tokenAddress); - uint256 balance = token.balanceOf(address(this)); - bool success = token.transfer(owner, balance); - require(success, "Token transfer failed"); + if (!token.transfer(owner, token.balanceOf(address(this)))) revert WithdrawalFailed(); } // Function to transfer ownership function transferOwnership(address newOwner) external onlyOwner { - require(newOwner != address(0), "New owner cannot be the zero address"); + if (newOwner == address(0)) revert InvalidOwner(); owner = newOwner; } + function _withdrawNative(uint256 amount) private { + (bool success,) = payable(owner).call{value: amount}(""); + if (!success) revert WithdrawalFailed(); + } + // Fallback function to receive Ether receive() external payable {} -} \ No newline at end of file +} diff --git a/scripts/bench-stress.ts b/scripts/bench-stress.ts index 5a11655..cdd2b3d 100644 --- a/scripts/bench-stress.ts +++ b/scripts/bench-stress.ts @@ -34,7 +34,7 @@ function poolAddress(id: number): Address { } function pair(id: number, token0: Address, token1: Address, reserve0: bigint, reserve1: bigint): PairInfo { - return { pairAddress: pairAddress(id), token0, token1, reserve0, reserve1, fee: 30 }; + return { pairAddress: pairAddress(id), token0, token1, reserve0, reserve1, fee: 30, variant: 'uniswap-v2', scale0: 1n, scale1: 1n }; } function pool(id: number, token0: Address, token1: Address): V3PoolConfig { diff --git a/src/constants.ts b/src/constants.ts index 7ae1a16..544b544 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -23,7 +23,7 @@ export const CONTRACTS = { } as const; export const ARBITRAGE_SEARCH_POLICY: ArbitrageSearchPolicy = { - topTokens: 7, + topTokens: 10, allowedProtocols: ['v2', 'v3', 'carbon'], allowProtocolMixing: true, maxRouteEdges: 5, @@ -35,9 +35,8 @@ export const ARBITRAGE_SEARCH_POLICY: ArbitrageSearchPolicy = { export const EXECUTION_POLICY = { executeTrades: true, - gasLimit: 1500000n, + gasLimit: 2500000n, baseFee: gasPrice('50.9'), - legacy: true, } as const; export const RUNTIME = { @@ -81,6 +80,13 @@ export const TOKENS: TokenConfig[] = [ liquidityAmount: tokenAmount('30', 6), minProfit: tokenAmount('0.09', 6), decimals: 6, + }, + { + name: 'USDT.Kava', + address: '0xB75D0B03c06A926e488e2659DF1A861F860bD3d1', + liquidityAmount: tokenAmount('20', 6), + minProfit: tokenAmount('0.09', 6), + decimals: 6, }, { name: 'WBTC', @@ -103,6 +109,13 @@ export const TOKENS: TokenConfig[] = [ minProfit: tokenAmount('10.50'), decimals: 18, }, + { + name: 'ISEI', + address: '0x5Cf6826140C1C56Ff49C808A1A75407Cd1DF9423', + liquidityAmount: tokenAmount('668'), + minProfit: tokenAmount('3.34'), + decimals: 18, + }, { name: 'SEIYAN', address: '0x5f0E07dFeE5832Faa00c63F2D33A0D79150E8598', diff --git a/src/execute.ts b/src/execute.ts index 6db70f8..3544791 100644 --- a/src/execute.ts +++ b/src/execute.ts @@ -50,7 +50,6 @@ class NonceTracker { async function sendTransactionNotification( hash: string, - type: 'flashswap' | 'direct', expectedProfit: bigint, tokenAddress?: Address ): Promise { @@ -60,7 +59,7 @@ async function sendTransactionNotification( const status = expectedProfit > 0n ? 'PROFIT' : 'WARNING'; const message = `${status}: Arbitrage Transaction\n\n` + - `Type: ${type === 'flashswap' ? 'Flash Swap' : 'Direct Swap'}\n` + + 'Type: Flash Swap\n' + `Expected Profit: ${formatTokenAmountWithSymbol(expectedProfit, token)}\n\n` + `Transaction:\n` + `${hash}\n\n` + @@ -101,7 +100,10 @@ export class OpportunityManager { private readonly submitOpportunity?: ( graph: FlashPoolLookup, opportunity: ExecutableOpportunity - ) => Promise + ) => Promise, + private readonly refreshOpportunity?: ( + opportunity: ExecutableOpportunity + ) => Promise ) { this.nonceTracker = new NonceTracker(networkConfig); } @@ -161,10 +163,18 @@ export class OpportunityManager { } try { + const current = this.refreshOpportunity + ? await this.refreshOpportunity(opp) + : opp; + if (!current) { + this.releasePairs(opp.pairs); + continue; + } + // Execute the opportunity const executed = await (this.submitOpportunity - ? this.submitOpportunity(graph, opp) - : this.executeArbitrageOpportunity(graph, opp)); + ? this.submitOpportunity(graph, current) + : this.executeArbitrageOpportunity(graph, current)); if (!executed) { this.releasePairs(opp.pairs); continue; @@ -172,8 +182,8 @@ export class OpportunityManager { if (RUNTIME.debug) { console.log('Submitted opportunity:', { - profit: opp.profit.toString(), - pairs: opp.pairs + profit: current.profit.toString(), + pairs: current.pairs }); } } catch (error) { @@ -219,10 +229,6 @@ export class OpportunityManager { const nonce = await this.nonceTracker.next(); - // Calculate dynamic gas fees based on opportunity's expected profit - const { maxFeePerGas, maxPriorityFeePerGas } = this.calculateGasFees(opportunity.profit); - - // Send transaction directly with gas parameters const hash = await this.networkConfig.walletClient.writeContract({ address: CONTRACTS.arbitrage as Address, abi: ArbABI, @@ -232,9 +238,8 @@ export class OpportunityManager { account: this.networkConfig.account, nonce, gas: EXECUTION_POLICY.gasLimit, - ...(EXECUTION_POLICY.legacy - ? { gasPrice: EXECUTION_POLICY.baseFee, type: 'legacy' as const } - : { maxFeePerGas, maxPriorityFeePerGas, type: 'eip1559' as const }), + gasPrice: EXECUTION_POLICY.baseFee, + type: 'legacy', }); if (RUNTIME.debug) { @@ -247,28 +252,10 @@ export class OpportunityManager { await sendTransactionNotification( hash, - 'flashswap', opportunity.profit, opportunity.path[opportunity.path.length - 1] ); return true; } - - // Helper function to calculate dynamic gas fees based on expected profit - public calculateGasFees(expectedProfit: bigint): { maxFeePerGas: bigint, maxPriorityFeePerGas: bigint } { - // Use 90% of expected profit as total gas fee budget - const totalGasFee = (expectedProfit * 90n) / 100n; - - // Calculate maxFeePerGas with (totalGasFee * 1_000_000_000) / gasLimit - const maxFeePerGas = (totalGasFee * 1n) / EXECUTION_POLICY.gasLimit; - - // // Use same value for maxPriorityFeePerGas as maxFeePerGas - const maxPriorityFeePerGas = maxFeePerGas; - - return { - maxFeePerGas, - maxPriorityFeePerGas - }; - } } diff --git a/src/market-db.ts b/src/market-db.ts index b8f450f..fdd2dba 100644 --- a/src/market-db.ts +++ b/src/market-db.ts @@ -3,6 +3,7 @@ import { mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; import { type Address } from 'viem'; import { type V2PoolMetadata } from './protocols/v2/metadata'; +import { type V2Variant } from './protocols/v2/types'; import { type CarbonPairMetadata } from './protocols/carbon/types'; import { type V3PoolConfig } from './protocols/v3/types'; @@ -16,6 +17,9 @@ type StoredPool = { token1: Address; fee: number; tickSpacing: number | null; + variant: V2Variant | null; + scale0: string | null; + scale1: string | null; }; export type MarketSnapshot = { @@ -32,6 +36,9 @@ type PoolRow = { token1: string; fee: number; tick_spacing: number | null; + variant: V2Variant | null; + scale0: string | null; + scale1: string | null; }; type CarbonPairRow = { @@ -81,6 +88,11 @@ function openMarketDb(path = marketDbPath()): Database { } function initMarketDb(db: Database): void { + const version = db.prepare('PRAGMA user_version').get() as { user_version: number }; + if (version.user_version < 2) { + db.exec('DROP TABLE IF EXISTS pools; DROP TABLE IF EXISTS carbon_pairs; PRAGMA user_version = 2'); + } + db.exec(` CREATE TABLE IF NOT EXISTS pools ( address TEXT PRIMARY KEY, @@ -89,7 +101,10 @@ function initMarketDb(db: Database): void { token0 TEXT NOT NULL, token1 TEXT NOT NULL, fee INTEGER NOT NULL, - tick_spacing INTEGER + tick_spacing INTEGER, + variant TEXT, + scale0 TEXT, + scale1 TEXT ) `); @@ -107,8 +122,8 @@ function initMarketDb(db: Database): void { function replaceStoredPools(db: Database, pools: readonly StoredPool[]): void { const insert = db.prepare(` - INSERT INTO pools (address, protocol, factory, token0, token1, fee, tick_spacing) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO pools (address, protocol, factory, token0, token1, fee, tick_spacing, variant, scale0, scale1) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const replace = db.transaction((rows: readonly StoredPool[]) => { db.exec('DELETE FROM pools'); @@ -120,7 +135,10 @@ function replaceStoredPools(db: Database, pools: readonly StoredPool[]): void { pool.token0, pool.token1, pool.fee, - pool.tickSpacing + pool.tickSpacing, + pool.variant, + pool.scale0, + pool.scale1 ); } }); @@ -129,7 +147,7 @@ function replaceStoredPools(db: Database, pools: readonly StoredPool[]): void { } function loadStoredPools(db: Database): StoredPool[] { - const rows = db.prepare('SELECT address, protocol, factory, token0, token1, fee, tick_spacing FROM pools').all() as PoolRow[]; + const rows = db.prepare('SELECT address, protocol, factory, token0, token1, fee, tick_spacing, variant, scale0, scale1 FROM pools').all() as PoolRow[]; return rows.map(row => ({ address: row.address as Address, protocol: row.protocol, @@ -138,6 +156,9 @@ function loadStoredPools(db: Database): StoredPool[] { token1: row.token1 as Address, fee: row.fee, tickSpacing: row.tick_spacing, + variant: row.variant, + scale0: row.scale0, + scale1: row.scale1, })); } @@ -186,6 +207,9 @@ function toStoredV2Pool(pool: V2PoolMetadata): StoredPool { token1: pool.token1, fee: pool.fee, tickSpacing: null, + variant: pool.variant, + scale0: pool.scale0.toString(), + scale1: pool.scale1.toString(), }; } @@ -198,6 +222,9 @@ function toStoredV3Pool(pool: V3PoolConfig): StoredPool { token1: pool.token1, fee: pool.fee, tickSpacing: pool.tickSpacing, + variant: null, + scale0: null, + scale1: null, }; } @@ -210,6 +237,9 @@ function storedV2Pools(pools: readonly StoredPool[]): V2PoolMetadata[] { token1: pool.token1, fee: pool.fee, factory: pool.factory || '', + variant: pool.variant!, + scale0: BigInt(pool.scale0!), + scale1: BigInt(pool.scale1!), })); } diff --git a/src/market-graph/market-graph.ts b/src/market-graph/market-graph.ts index 23b7903..111222a 100644 --- a/src/market-graph/market-graph.ts +++ b/src/market-graph/market-graph.ts @@ -18,7 +18,7 @@ import { type V3TickUpdate, } from '../protocols/v3/types'; import { compareFractions } from '../fractions'; -import { FEE_DENOMINATOR, swapV2 } from '../protocols/v2/quote'; +import { quoteV2ExactInput, v2MarginalRate } from '../protocols/v2/quote'; import { carbonMarginalRate, carbonSourceAmountForFullOrder, @@ -27,7 +27,6 @@ import { type CarbonAllocation, } from '../protocols/carbon/quote'; import { Q96, quoteV3MultiRangeExactInput, V3_FEE_DENOMINATOR } from '../protocols/v3/quote'; -import { feeMultiplier } from '../values'; import { protocolPlugin } from '../protocols/registry'; import { type AnyMarketEdge, @@ -456,6 +455,7 @@ export class MarketGraph { if (excluded.has(this.edges[edgeIndex].poolIndex)) continue; const edge = this.edges[edgeIndex].edge; if (!protocolPlugin(edge.protocol).flashLoanFee) continue; + if (edge.protocol === 'v2' && edge.variant !== 'uniswap-v2') continue; if (edge.protocol === 'v2' && edge.reserveIn <= amountIn) continue; const inputCapacity = this.edgeInputCapacity(edge); if (edge.protocol === 'v3' && inputCapacity <= amountIn) continue; @@ -478,7 +478,7 @@ export class MarketGraph { return { amountIn, amountOut: 0n, profit: -1n, complete: false }; } - const amountOut = swapV2(amountIn, edge.reserveIn, edge.reserveOut, edge.fee); + const amountOut = quoteV2ExactInput(amountIn, edge); return { amountIn, amountOut, @@ -535,6 +535,24 @@ export class MarketGraph { const token0Index = this.tokenIndex(pair.token0); const token1Index = this.tokenIndex(pair.token1); + const forward = { + variant: pair.variant, + reserveIn: pair.reserve0, + reserveOut: pair.reserve1, + scaleIn: pair.scale0, + scaleOut: pair.scale1, + fee: pair.fee, + }; + const reverse = { + variant: pair.variant, + reserveIn: pair.reserve1, + reserveOut: pair.reserve0, + scaleIn: pair.scale1, + scaleOut: pair.scale0, + fee: pair.fee, + }; + const forwardRate = v2MarginalRate(forward); + const reverseRate = v2MarginalRate(reverse); this.upsertEdge({ id: this.edgeId('v2', poolIndex, 'token0ToToken1'), @@ -543,11 +561,9 @@ export class MarketGraph { to: pair.token1, poolAddress: pair.pairAddress, direction: 'token0ToToken1', - fee: pair.fee, - reserveIn: pair.reserve0, - reserveOut: pair.reserve1, - rateNumerator: pair.reserve1 * feeMultiplier(pair.fee), - rateDenominator: pair.reserve0 * FEE_DENOMINATOR, + ...forward, + rateNumerator: forwardRate.numerator, + rateDenominator: forwardRate.denominator, liquidity: pair.reserve0, }, token0Index, token1Index, poolIndex); @@ -558,11 +574,9 @@ export class MarketGraph { to: pair.token0, poolAddress: pair.pairAddress, direction: 'token1ToToken0', - fee: pair.fee, - reserveIn: pair.reserve1, - reserveOut: pair.reserve0, - rateNumerator: pair.reserve0 * feeMultiplier(pair.fee), - rateDenominator: pair.reserve1 * FEE_DENOMINATOR, + ...reverse, + rateNumerator: reverseRate.numerator, + rateDenominator: reverseRate.denominator, liquidity: pair.reserve1, }, token1Index, token0Index, poolIndex); } @@ -887,7 +901,11 @@ export class MarketGraph { const cached = this.flashEdgesCache.get(tokenIndex); if (cached) return cached; const edgeIndexes = this.tokens[tokenIndex].edgeIndexes - .filter(edgeIndex => Boolean(protocolPlugin(this.edges[edgeIndex].edge.protocol).flashLoanFee)) + .filter(edgeIndex => { + const edge = this.edges[edgeIndex].edge; + return Boolean(protocolPlugin(edge.protocol).flashLoanFee) && + (edge.protocol !== 'v2' || edge.variant === 'uniswap-v2'); + }) .sort((aIndex, bIndex) => { const a = this.edges[aIndex].edge; const b = this.edges[bIndex].edge; diff --git a/src/market-graph/types.ts b/src/market-graph/types.ts index 4fb2e4c..de09a47 100644 --- a/src/market-graph/types.ts +++ b/src/market-graph/types.ts @@ -1,6 +1,6 @@ import { type Address } from 'viem'; import { type CarbonOrder } from '../protocols/carbon/types'; -import { type SwapDirection } from '../protocols/v2/types'; +import { type SwapDirection, type V2Variant } from '../protocols/v2/types'; export type MarketProtocol = 'v2' | 'v3' | 'carbon'; @@ -34,6 +34,9 @@ export type V2MarketEdge = MarketEdge & { protocol: 'v2'; reserveIn: bigint; reserveOut: bigint; + variant: V2Variant; + scaleIn: bigint; + scaleOut: bigint; }; export type V3MarketEdge = MarketEdge & { diff --git a/src/opportunities/opportunity-engine.ts b/src/opportunities/opportunity-engine.ts index aeb39ce..856b057 100644 --- a/src/opportunities/opportunity-engine.ts +++ b/src/opportunities/opportunity-engine.ts @@ -8,6 +8,7 @@ import { MarketGraph } from '../market-graph/market-graph'; import { sizeRoute } from '../market-graph/route-sizer'; import { type ArbitrageSearchPolicy, type FlashPoolCandidate } from '../market-graph/types'; import { type PairInfo, type ReserveUpdate } from '../protocols/v2/types'; +import { encodeV2RouteData } from '../protocols/v2/execution'; import { type V3BitmapWord, type V3BitmapWordUpdate, @@ -175,7 +176,9 @@ export class OpportunityEngine { fees.push(edge.fee); routeData.push(edge.protocol === 'carbon' && edgeIndex !== undefined ? this.encodeCarbonRouteData(edgeIndex, amount) - : '0x'); + : edge.protocol === 'v2' + ? encodeV2RouteData(edge.variant) + : '0x'); const quote = edgeIndex !== undefined ? this.graph.quoteEdgeAt(edgeIndex, amount) diff --git a/src/opportunities/opportunity-workflow.ts b/src/opportunities/opportunity-workflow.ts index 1167629..0bf4900 100644 --- a/src/opportunities/opportunity-workflow.ts +++ b/src/opportunities/opportunity-workflow.ts @@ -18,13 +18,41 @@ export type OpportunityWorkflowRequest = { export function createOpportunityScanner( engine: OpportunityEngine, - networkConfig: NetworkConfig + networkConfig: NetworkConfig, + reconcileMarkets?: (addresses: readonly Address[]) => Promise ): (request?: OpportunityWorkflowRequest) => Promise { - const manager = EXECUTION_POLICY.executeTrades ? new OpportunityManager(networkConfig) : null; + const manager = EXECUTION_POLICY.executeTrades + ? new OpportunityManager( + networkConfig, + undefined, + reconcileMarkets ? opportunity => refreshOpportunity(engine, opportunity, reconcileMarkets) : undefined + ) + : null; if (manager) void manager.warmNonce(); return request => scanAndExecuteOpportunities(engine, manager, request); } +async function refreshOpportunity( + engine: OpportunityEngine, + opportunity: ExecutableOpportunity, + reconcileMarkets: (addresses: readonly Address[]) => Promise +): Promise { + await reconcileMarkets(opportunity.pairs); + const refreshed = engine.findOpportunities({ + startTokens: [opportunity.path[0]], + changedPairs: opportunity.pairs, + }); + return refreshed.find(candidate => sameRoute(candidate, opportunity)) ?? null; +} + +function sameRoute(left: ExecutableOpportunity, right: ExecutableOpportunity): boolean { + return left.pairs.length === right.pairs.length + && left.path.length === right.path.length + && left.pairs.every((pair, index) => pair.toLowerCase() === right.pairs[index].toLowerCase()) + && left.path.every((token, index) => token.toLowerCase() === right.path[index].toLowerCase()) + && left.protocols.every((protocol, index) => protocol === right.protocols[index]); +} + async function scanAndExecuteOpportunities( engine: OpportunityEngine, manager: OpportunityManager | null, diff --git a/src/protocols/carbon/runtime.ts b/src/protocols/carbon/runtime.ts index c0c957d..90119a9 100644 --- a/src/protocols/carbon/runtime.ts +++ b/src/protocols/carbon/runtime.ts @@ -340,19 +340,25 @@ function field(value: any, name: string, index: number): TValue { export class CarbonEventAdapter implements ProtocolEventAdapter { readonly id = 'carbon'; - private readonly controllers = new Set( - CARBON_CONTROLLERS.filter(controller => controller.enabled).map(controller => controller.address.toLowerCase()) - ); + private readonly controllerAddresses = CARBON_CONTROLLERS + .filter(controller => controller.enabled) + .map(controller => controller.address); + private readonly controllers = new Set(this.controllerAddresses.map(address => address.toLowerCase())); constructor(private readonly store: CarbonStrategyStore) {} + addresses(): readonly Address[] { + return this.controllerAddresses; + } + + owns(address: Address): boolean { + return this.controllers.has(address.toLowerCase()); + } + async watch(client: PublicClient, onLogs: (logs: any[]) => void | Promise, onError: (error: any) => void | Promise) { - const addresses = CARBON_CONTROLLERS - .filter(controller => controller.enabled) - .map(controller => controller.address); - if (addresses.length === 0) return []; + if (this.controllerAddresses.length === 0) return []; const unwatch = await client.watchContractEvent({ - address: addresses, + address: this.controllerAddresses, abi: CARBON_CONTROLLER_EVENT_ABI, strict: true, onLogs, @@ -367,7 +373,15 @@ export class CarbonEventAdapter implements ProtocolEventAdapter { } async reconcile(logs: readonly any[]): Promise { - if (logs.length > 0) await this.store.loadAll(); + const addresses: Address[] = []; + for (const log of logs) { + if (log.address) addresses.push(log.address); + } + await this.reconcileAddresses(addresses); + } + + async reconcileAddresses(addresses: readonly Address[]): Promise { + if (addresses.some(address => this.owns(address))) await this.store.loadAll(); } async apply(logs: any[]): Promise { diff --git a/src/protocols/registry.ts b/src/protocols/registry.ts index baec84c..d4bbc06 100644 --- a/src/protocols/registry.ts +++ b/src/protocols/registry.ts @@ -4,11 +4,7 @@ import { v2Plugin } from './v2'; import { v3Plugin } from './v3'; // Discovery order is intentional: Carbon limits itself to the V2/V3 token universe. -export function createProtocolPlugins(): readonly ProtocolPlugin[] { - return [v2Plugin, v3Plugin, createCarbonPlugin()]; -} - -export const PROTOCOL_PLUGINS = createProtocolPlugins(); +export const PROTOCOL_PLUGINS: readonly ProtocolPlugin[] = [v2Plugin, v3Plugin, createCarbonPlugin()]; const PLUGIN_BY_ID = new Map(PROTOCOL_PLUGINS.map(plugin => [plugin.id, plugin])); diff --git a/src/protocols/v2/config.ts b/src/protocols/v2/config.ts index 43b74be..7fd8b27 100644 --- a/src/protocols/v2/config.ts +++ b/src/protocols/v2/config.ts @@ -5,17 +5,17 @@ export type DexFactoryConfig = { name: string; address: Address; fee: number; - volatile: boolean; + kind: 'uniswap-v2' | 'solidly'; }; export const V2_DISCOVERY_POLICY = { batchSize: 200, - woofReserveBatchSize: 5, + solidlyReserveBatchSize: 5, maxPairAgeSeconds: 700 * 24 * 60 * 60, minOtherTokenLiquidity: tokenAmount('500'), } as const; export const V2_FACTORIES: readonly DexFactoryConfig[] = [ - { name: 'dragonV1', address: '0x71f6b49ae1558357bBb5A6074f1143c46cBcA03d', fee: 30, volatile: false }, - { name: 'yakafinance', address: '0xd45dAff288075952822d5323F1d571e73435E929', fee: 18, volatile: true }, + { name: 'dragonV1', address: '0x71f6b49ae1558357bBb5A6074f1143c46cBcA03d', fee: 30, kind: 'uniswap-v2' }, + { name: 'yakafinance', address: '0xd45dAff288075952822d5323F1d571e73435E929', fee: 18, kind: 'solidly' }, ]; diff --git a/src/protocols/v2/execution.ts b/src/protocols/v2/execution.ts index a303c7a..c5fbac1 100644 --- a/src/protocols/v2/execution.ts +++ b/src/protocols/v2/execution.ts @@ -1,3 +1,10 @@ +import { type Hex } from 'viem'; +import { type V2Variant } from './types'; + +export function encodeV2RouteData(variant: V2Variant): Hex { + return variant === 'solidly-stable' ? '0x01' : '0x'; +} + export function v2FlashLoanFee(fee: number, amount: bigint): bigint { const rawFee = BigInt(fee); return (amount * rawFee) / (10_000n - rawFee) + 1n; diff --git a/src/protocols/v2/metadata.ts b/src/protocols/v2/metadata.ts index 4ff80f1..a27c9bb 100644 --- a/src/protocols/v2/metadata.ts +++ b/src/protocols/v2/metadata.ts @@ -1,8 +1,16 @@ -import { type Address } from 'viem'; +import { parseAbi, type Address } from 'viem'; import UniswapFlashQueryABI from '../../ABI/UniswapFlashQuery.json'; import bannedTokens from '../../bannedtax.json'; import { CONTRACTS, RUNTIME } from '../../constants'; import { V2_DISCOVERY_POLICY, V2_FACTORIES } from './config'; +import { type V2Variant } from './types'; + +const BASE_V1_PAIR_ABI = parseAbi([ + 'function metadata() view returns (uint256 scale0, uint256 scale1, uint256 reserve0, uint256 reserve1, bool stable, address token0, address token1)', +]); +const BASE_V1_FACTORY_ABI = parseAbi([ + 'function getFee(bool stable) view returns (uint256)', +]); export type V2PoolMetadata = { pairAddress: Address; @@ -10,9 +18,23 @@ export type V2PoolMetadata = { token1: Address; fee: number; factory: string; + variant: V2Variant; + scale0: bigint; + scale1: bigint; }; type V2Client = { readContract(parameters: any): Promise }; +type BaseV1Metadata = { + scale0: bigint; + scale1: bigint; + reserve0: bigint; + reserve1: bigint; + stable: boolean; + token0: Address; + token1: Address; +}; +type RawBaseV1Metadata = BaseV1Metadata | readonly [bigint, bigint, bigint, bigint, boolean, Address, Address]; +type SolidlyFees = { stable: number; volatile: number }; const bannedTokenSet = new Set(bannedTokens.map(token => token.toLowerCase())); export async function discoverV2PoolMetadata(client: V2Client): Promise { @@ -20,8 +42,13 @@ export async function discoverV2PoolMetadata(client: V2Client): Promise { try { const pairs = await client.readContract({ @@ -56,7 +84,7 @@ async function getPairsInRange( functionName: 'getPairsByIndexRange', args: [factory.address, BigInt(start), BigInt(stop)], }) as Address[][]; - return pairs + const discovered = pairs .filter(([token0, token1]) => !bannedTokenSet.has(token0.toLowerCase()) && !bannedTokenSet.has(token1.toLowerCase())) .map(([token0, token1, pairAddress]) => ({ pairAddress, @@ -64,9 +92,70 @@ async function getPairsInRange( token1, factory: factory.name, fee: factory.fee, + variant: 'uniswap-v2' as const, + scale0: 1n, + scale1: 1n, })); + if (factory.kind !== 'solidly' || discovered.length === 0) return discovered; + + const stable = await client.readContract({ + address: CONTRACTS.flashQuery as Address, + abi: UniswapFlashQueryABI, + functionName: 'filterVolatileHermesPairs', + args: [discovered.map(pair => pair.pairAddress)], + }) as boolean[]; + + return Promise.all(discovered.map(async (pair, index) => { + const isStable = stable[index]; + const metadata = isStable + ? normalizeBaseV1Metadata(await client.readContract({ + address: pair.pairAddress, + abi: BASE_V1_PAIR_ABI, + functionName: 'metadata', + }) as RawBaseV1Metadata) + : null; + return { + ...pair, + fee: isStable ? fees!.stable : fees!.volatile, + variant: isStable ? 'solidly-stable' : 'solidly-volatile', + scale0: metadata?.scale0 ?? 1n, + scale1: metadata?.scale1 ?? 1n, + }; + })); } catch (error) { - if (RUNTIME.debug) console.error(`Error fetching V2 pools for ${factory.name}:`, error); + if (!String(error).toLowerCase().includes('revert')) throw error; + if (stop - start > 1) { + const middle = start + Math.floor((stop - start) / 2); + if (RUNTIME.debug) console.warn(`Retrying ${factory.name} V2 range ${start}-${stop} as smaller calls`); + return [ + ...await getPairsInRange(client, factory, start, middle, fees), + ...await getPairsInRange(client, factory, middle, stop, fees), + ]; + } + console.warn(`Skipping reverting ${factory.name} V2 pair index ${start}`); return []; } } + +async function getSolidlyFees(client: V2Client, factory: Address): Promise { + const [stable, volatile] = await Promise.all([true, false].map(stable => client.readContract({ + address: factory, + abi: BASE_V1_FACTORY_ABI, + functionName: 'getFee', + args: [stable], + }) as Promise)); + return { stable: Number(stable), volatile: Number(volatile) }; +} + +function normalizeBaseV1Metadata(metadata: RawBaseV1Metadata): BaseV1Metadata { + if (!Array.isArray(metadata)) return metadata as BaseV1Metadata; + return { + scale0: metadata[0], + scale1: metadata[1], + reserve0: metadata[2], + reserve1: metadata[3], + stable: metadata[4], + token0: metadata[5], + token1: metadata[6], + }; +} diff --git a/src/protocols/v2/quote.ts b/src/protocols/v2/quote.ts index 949a2bb..f14fba1 100644 --- a/src/protocols/v2/quote.ts +++ b/src/protocols/v2/quote.ts @@ -1,9 +1,83 @@ import { feeMultiplier } from '../../values'; +import { type V2QuoteState } from './types'; export const FEE_DENOMINATOR = 10000n; +const ONE = 10n ** 18n; export function swapV2(amountIn: bigint, reserveIn: bigint, reserveOut: bigint, fee: number): bigint { -// const amountInWithFee = amountIn * (FEE_DENOMINATOR - BigInt(fee)); const amountInWithFee = amountIn * (feeMultiplier(fee)); return (amountInWithFee * reserveOut) / (reserveIn * FEE_DENOMINATOR + amountInWithFee); } + +export function quoteV2ExactInput(amountIn: bigint, state: V2QuoteState): bigint { + return state.variant === 'solidly-stable' + ? swapSolidlyStable(amountIn, state) + : swapV2(amountIn, state.reserveIn, state.reserveOut, state.fee); +} + +export function swapSolidlyStable(amountIn: bigint, state: V2QuoteState): bigint { + if (amountIn <= 0n || state.reserveIn <= 0n || state.reserveOut <= 0n || state.scaleIn <= 0n || state.scaleOut <= 0n) return 0n; + + const netAmountIn = amountIn - (amountIn * BigInt(state.fee)) / FEE_DENOMINATOR; + const reserveIn = (state.reserveIn * ONE) / state.scaleIn; + const reserveOut = (state.reserveOut * ONE) / state.scaleOut; + const normalizedIn = (netAmountIn * ONE) / state.scaleIn; + const invariant = stableK(state.reserveIn, state.reserveOut, state.scaleIn, state.scaleOut); + const nextReserveOut = stableY(normalizedIn + reserveIn, invariant, reserveOut); + return ((reserveOut - nextReserveOut) * state.scaleOut) / ONE; +} + +export function v2MarginalRate(state: V2QuoteState): { numerator: bigint; denominator: bigint } { + if (state.variant !== 'solidly-stable') { + return { + numerator: state.reserveOut * feeMultiplier(state.fee), + denominator: state.reserveIn * FEE_DENOMINATOR, + }; + } + const probe = state.reserveIn / 1_000_000n || 1n; + return { numerator: quoteV2ExactInput(probe, state), denominator: probe }; +} + +function stableK(x: bigint, y: bigint, scaleX: bigint, scaleY: bigint): bigint { + const normalizedX = (x * ONE) / scaleX; + const normalizedY = (y * ONE) / scaleY; + const a = (normalizedX * normalizedY) / ONE; + const b = (normalizedX * normalizedX) / ONE + (normalizedY * normalizedY) / ONE; + return (a * b) / ONE; +} + +function stableF(x: bigint, y: bigint): bigint { + return (x * ((((y * y) / ONE) * y) / ONE)) / ONE + + (((((x * x) / ONE) * x) / ONE) * y) / ONE; +} + +function stableD(x: bigint, y: bigint): bigint { + return (3n * x * ((y * y) / ONE)) / ONE + ((((x * x) / ONE) * x) / ONE); +} + +function stableY(x: bigint, invariant: bigint, yStart: bigint): bigint { + let y = yStart; + for (let i = 0; i < 255; i++) { + const k = stableF(x, y); + const denominator = stableD(x, y); + if (denominator === 0n) throw new Error('Stable pool derivative is zero'); + + if (k < invariant) { + let dy = ((invariant - k) * ONE) / denominator; + if (dy === 0n) { + if (k === invariant) return y; + if (stableF(x, y + 1n) > invariant) return y + 1n; + dy = 1n; + } + y += dy; + } else { + let dy = ((k - invariant) * ONE) / denominator; + if (dy === 0n) { + if (k === invariant || stableF(x, y - 1n) < invariant) return y; + dy = 1n; + } + y -= dy; + } + } + throw new Error('Stable pool solver did not converge'); +} diff --git a/src/protocols/v2/runtime.ts b/src/protocols/v2/runtime.ts index 55a1e37..572acac 100644 --- a/src/protocols/v2/runtime.ts +++ b/src/protocols/v2/runtime.ts @@ -66,28 +66,6 @@ function hasEnoughLiquidity(pair: DiscoveredPairInfo): boolean { return hasEnoughLiquidity; } -async function filterVolatilePairs( - client: V2Client, - pairs: DiscoveredPairInfo[] -): Promise { - try { - const isVolatile = await client.readContract({ - address: CONTRACTS.flashQuery as Address, - abi: UniswapFlashQueryABI, - functionName: 'filterVolatileHermesPairs', - args: [pairs.map(p => p.pairAddress)], - }) as boolean[]; - - return isVolatile; - } catch (error) { - if (RUNTIME.debug) { - console.error('Error checking volatile pairs:', error); - } - // In case of error, assume all pairs are volatile (false) - return pairs.map(() => false); - } -} - async function getReservesForPairs( client: V2Client, pairs: DiscoveredPairInfo[] @@ -134,9 +112,9 @@ async function getReservesWithRetry( for (const factory of Object.keys(pairsByFactory)) { const factoryPairs = pairsByFactory[factory]; const factoryConfig = DEX_FACTORIES.find(f => f.name === factory); - const isWoofFactory = factoryConfig?.volatile ?? false; - const batchSize = isWoofFactory - ? PAIR_DISCOVERY_POLICY.woofReserveBatchSize + const isSolidlyFactory = factoryConfig?.kind === 'solidly'; + const batchSize = isSolidlyFactory + ? PAIR_DISCOVERY_POLICY.solidlyReserveBatchSize : PAIR_DISCOVERY_POLICY.batchSize; if (RUNTIME.debug) { @@ -150,31 +128,17 @@ async function getReservesWithRetry( console.log(`Fetching reserves for ${batch.length} pairs from ${factory} (${i + 1} to ${i + batch.length})`); } - let filteredBatch = batch; - if (isWoofFactory) { - const isStablePair = await filterVolatilePairs(client, batch); - filteredBatch = batch.filter((_, index) => !isStablePair[index]); - - if (RUNTIME.debug && batch.length !== filteredBatch.length) { - console.log(`Filtered out ${batch.length - filteredBatch.length} stable pairs from Woof factory`); - } - - if (filteredBatch.length === 0) { - continue; - } - } - - const pairsWithReserves = await getReservesForPairs(client, filteredBatch); + const pairsWithReserves = await getReservesForPairs(client, batch); const validPairs = pairsWithReserves.filter(pair => isPairActive(pair.lastTimestamp) && hasEnoughLiquidity(pair) ); - const skippedCount = filteredBatch.length - validPairs.length; + const skippedCount = batch.length - validPairs.length; if (skippedCount > 0 && RUNTIME.debug) { console.log(`Skipped ${skippedCount} pairs (${ - filteredBatch.length - validPairs.length - pairsWithReserves.filter(p => !isPairActive(p.lastTimestamp)).length + batch.length - validPairs.length - pairsWithReserves.filter(p => !isPairActive(p.lastTimestamp)).length } with zero reserves, ${ pairsWithReserves.filter(p => !isPairActive(p.lastTimestamp)).length } inactive, ${ @@ -249,6 +213,14 @@ export class V2EventAdapter implements ProtocolEventAdapter { ); } + addresses(): readonly Address[] { + return [...this.pools.values()].map(pool => pool.pairAddress); + } + + owns(address: Address): boolean { + return this.pools.has(address.toLowerCase()); + } + async watch(client: PublicClient, onLogs: (logs: any[]) => void | Promise, onError: (error: any) => void | Promise) { if (this.pools.size === 0) return []; const unwatch = await client.watchContractEvent({ @@ -267,10 +239,18 @@ export class V2EventAdapter implements ProtocolEventAdapter { } async reconcile(logs: readonly any[]): Promise { - const touched = new Map(); + const addresses: Address[] = []; for (const log of logs) { - const key = log.address?.toLowerCase(); - const pool = key ? this.pools.get(key) : undefined; + if (log.address) addresses.push(log.address); + } + await this.reconcileAddresses(addresses); + } + + async reconcileAddresses(addresses: readonly Address[]): Promise { + const touched = new Map(); + for (const address of addresses) { + const key = address.toLowerCase(); + const pool = this.pools.get(key); if (pool) touched.set(key, pool); } const pairs = await refreshKnownPairsInfo(this.client, [...touched.values()]); @@ -298,14 +278,7 @@ export class V2EventAdapter implements ProtocolEventAdapter { for (const update of updates) { const pool = this.pools.get(update.pairAddress.toLowerCase()); if (!pool) continue; - this.engine.addPair({ - pairAddress: pool.pairAddress, - token0: pool.token0, - token1: pool.token1, - fee: pool.fee, - reserve0: update.reserve0, - reserve1: update.reserve1, - }); + this.engine.addPair({ ...pool, reserve0: update.reserve0, reserve1: update.reserve1 }); } await this.scan(updates.map(update => update.pairAddress), updates.map(update => update.pairAddress)); } diff --git a/src/protocols/v2/types.ts b/src/protocols/v2/types.ts index 4e5e077..14097ea 100644 --- a/src/protocols/v2/types.ts +++ b/src/protocols/v2/types.ts @@ -1,5 +1,7 @@ import { type Address } from 'viem'; +export type V2Variant = 'uniswap-v2' | 'solidly-volatile' | 'solidly-stable'; + export type PairInfo = { pairAddress: Address; token0: Address; @@ -7,6 +9,18 @@ export type PairInfo = { reserve0: bigint; reserve1: bigint; fee: number; + variant: V2Variant; + scale0: bigint; + scale1: bigint; +}; + +export type V2QuoteState = { + variant: V2Variant; + reserveIn: bigint; + reserveOut: bigint; + scaleIn: bigint; + scaleOut: bigint; + fee: number; }; export type ReserveUpdate = { diff --git a/src/protocols/v3/config.ts b/src/protocols/v3/config.ts index 96db0eb..601c467 100644 --- a/src/protocols/v3/config.ts +++ b/src/protocols/v3/config.ts @@ -148,5 +148,23 @@ export const V3_POOLS: V3PoolConfig[] = [ fee: 500, tickSpacing: 10, enabled: true, + }, + { + name: 'sailor-usdc-wsei', + address: '0x80fe558c54f1f43263e08f0e1fa3e02d8b897f93', + token0: '0xe15fc38f6d8c56af07bbcbe3baf5708a2bf42392', + token1: '0xe30fedd158a2e3b13e9badaeabafc5516e95e8c7', + fee: 3000, + tickSpacing: 60, + enabled: true, + }, + { + name: 'sailor-usdc.n-usdc', + address: '0xdc39167b80874765a334be78a378417bb42aae26', + token0: '0x3894085ef7ff0f0aedf52e2a2704928d1ec074f1', + token1: '0xe15fc38f6d8c56af07bbcbe3baf5708a2bf42392', + fee: 100, + tickSpacing: 1, + enabled: true, } ]; diff --git a/src/protocols/v3/runtime.ts b/src/protocols/v3/runtime.ts index 5055ef0..2a5c1e3 100644 --- a/src/protocols/v3/runtime.ts +++ b/src/protocols/v3/runtime.ts @@ -206,6 +206,14 @@ export class V3EventAdapter implements ProtocolEventAdapter { for (const pool of pools) this.pools.set(pool.address.toLowerCase(), pool); } + addresses(): readonly Address[] { + return [...this.pools.values()].map(pool => pool.address); + } + + owns(address: Address): boolean { + return this.pools.has(address.toLowerCase()); + } + async watch(client: PublicClient, onLogs: (logs: any[]) => void | Promise, onError: (error: any) => void | Promise) { if (this.pools.size === 0) return []; const unwatch = await client.watchContractEvent({ @@ -226,10 +234,18 @@ export class V3EventAdapter implements ProtocolEventAdapter { } async reconcile(logs: readonly any[]): Promise { - const touched = new Map(); + const addresses: Address[] = []; for (const log of logs) { - const key = log.address?.toLowerCase(); - const pool = key ? this.pools.get(key) : undefined; + if (log.address) addresses.push(log.address); + } + await this.reconcileAddresses(addresses); + } + + async reconcileAddresses(addresses: readonly Address[]): Promise { + const touched = new Map(); + for (const address of addresses) { + const key = address.toLowerCase(); + const pool = this.pools.get(key); if (pool) touched.set(key, pool); } if (touched.size > 0) await loadConfiguredV3StartupState(this.client, this.engine, [...touched.values()]); diff --git a/src/runtime/arbitrage-bot.ts b/src/runtime/arbitrage-bot.ts index 71c4c1c..0be9001 100644 --- a/src/runtime/arbitrage-bot.ts +++ b/src/runtime/arbitrage-bot.ts @@ -5,7 +5,7 @@ import { loadMarketSnapshot } from '../market-db'; import { initializeNetwork } from '../network'; import { OpportunityEngine } from '../opportunities/opportunity-engine'; import { createOpportunityScanner } from '../opportunities/opportunity-workflow'; -import { createProtocolPlugins, PROTOCOL_PLUGINS } from '../protocols/registry'; +import { PROTOCOL_PLUGINS } from '../protocols/registry'; import { LatestUpdateScheduler } from './event-scheduler'; type ScanUpdate = { key: string; releasedPairs: readonly Address[] }; @@ -29,7 +29,12 @@ export async function runArbitrageBot(): Promise { ARBITRAGE_SEARCH_POLICY, [] ); - const scanOpportunities = createOpportunityScanner(graph, network); + let monitor!: EventMonitor; + const scanOpportunities = createOpportunityScanner( + graph, + network, + addresses => monitor.reconcileMarkets(addresses) + ); const scanScheduler = new LatestUpdateScheduler( async updates => { const releasedPairs = new Map(); @@ -45,11 +50,11 @@ export async function runArbitrageBot(): Promise { ); const scheduleScan = (changedPairs: readonly string[], releasedPairs: readonly Address[] = []) => scanScheduler.submit(changedPairs.map(key => ({ key, releasedPairs }))); - const runtimePlugins = createProtocolPlugins(); + const runtimePlugins = PROTOCOL_PLUGINS; const eventAdapters = runtimePlugins .map(plugin => plugin.events({ client: network.client, catalog, engine: graph, scan: scheduleScan })) .filter(adapter => adapter !== null); - const monitor = new EventMonitor(network, eventAdapters); + monitor = new EventMonitor(network, eventAdapters); console.log('Starting market event feed in buffering mode...'); await monitor.startBuffering(); @@ -67,7 +72,7 @@ export async function runArbitrageBot(): Promise { if (RUNTIME.debug) console.log(`Loaded live state for ${runtimePlugins.map(plugin => plugin.id).join(', ')}`); console.log('Reconciling events received during startup...'); - await monitor.activate(); + await monitor.activate(hydrationStartedAtBlock); } catch (error) { await monitor.stop(); throw error; diff --git a/src/runtime/chain-cursor.ts b/src/runtime/chain-cursor.ts index 0e85375..6fd08d7 100644 --- a/src/runtime/chain-cursor.ts +++ b/src/runtime/chain-cursor.ts @@ -1,5 +1,3 @@ -import { type Address } from 'viem'; - export type ChainCursor = { blockNumber: bigint; transactionIndex: number; @@ -40,10 +38,6 @@ export function advanceCursor(cursor: ChainCursor | undefined, log: any): ChainC return cursor; } -export function addressMap(addresses: readonly Address[]): Map { - return new Map(addresses.map(address => [address.toLowerCase(), address])); -} - function compareLogField(left: unknown, right: unknown): number { const a = logFieldToBigInt(left); const b = logFieldToBigInt(right); diff --git a/src/runtime/event-monitor.ts b/src/runtime/event-monitor.ts index 3c417e9..cff4f98 100644 --- a/src/runtime/event-monitor.ts +++ b/src/runtime/event-monitor.ts @@ -1,4 +1,4 @@ -import { type PublicClient } from 'viem'; +import { type Address, type PublicClient } from 'viem'; import { RUNTIME } from '../constants'; import { advanceCursor, chainLogBlockNumber, type ChainCursor, compareChainLogs, isLogAfterCursor } from './chain-cursor'; import { type ProtocolEventAdapter } from './protocol-event-adapter'; @@ -24,6 +24,7 @@ export class EventMonitor { private firstBufferedBlock: bigint | null = null; private lastBufferedBlock: bigint | null = null; private bufferedLogCount = 0; + private hydrationFloor = 0n; constructor( network: any, @@ -62,13 +63,15 @@ export class EventMonitor { } } - async activate(): Promise { + async activate(hydrationFloor = 0n): Promise { if (!this.running || !this.buffering) return; + if (hydrationFloor > this.hydrationFloor) this.hydrationFloor = hydrationFloor; while (this.buffered.size > 0) { const entries = [...this.buffered.values()].sort((a, b) => compareChainLogs(a.log, b.log)); this.buffered.clear(); const byAdapter = new Map(); for (const entry of entries) { + if (chainLogBlockNumber(entry.log) <= this.hydrationFloor) continue; this.markApplied(entry.adapter, entry.log); const logs = byAdapter.get(entry.adapter); if (logs) logs.push(entry.log); @@ -83,6 +86,17 @@ export class EventMonitor { console.log(`Market event feed caught up and is now live (${range})`); } + async reconcileMarkets(addresses: readonly Address[]): Promise { + const blockNumber = await this.client.getBlockNumber(); + await Promise.all(this.adapters.map(async adapter => { + const owned = addresses.filter(address => adapter.owns(address)); + if (owned.length === 0) return; + await adapter.reconcileAddresses(owned); + for (const address of owned) this.markReconciled(adapter, address, blockNumber); + })); + return blockNumber; + } + async stop(): Promise { await this.stopInternal(false); } @@ -113,6 +127,7 @@ export class EventMonitor { private freshLogs(adapter: ProtocolEventAdapter, logs: any[]): any[] { let count = 0; for (const log of logs) { + if (chainLogBlockNumber(log) <= this.hydrationFloor) continue; const key = this.cursorKey(adapter, log); if (!key) continue; const cursor = this.cursors.get(key); @@ -136,6 +151,17 @@ export class EventMonitor { return address ? `${adapter.id}:${address}` : null; } + private markReconciled(adapter: ProtocolEventAdapter, address: Address, blockNumber: bigint): void { + const key = `${adapter.id}:${address.toLowerCase()}`; + const cursor = this.cursors.get(key); + const reconciled = { + blockNumber, + transactionIndex: Number.MAX_SAFE_INTEGER, + logIndex: Number.MAX_SAFE_INTEGER, + }; + if (!cursor || cursor.blockNumber <= blockNumber) this.cursors.set(key, reconciled); + } + private async stopInternal(preserveCursors: boolean): Promise { if (!this.running) return; this.running = false; @@ -148,7 +174,10 @@ export class EventMonitor { this.lastBufferedBlock = null; this.bufferedLogCount = 0; for (const adapter of this.adapters) adapter.clear?.(); - if (!preserveCursors) this.cursors.clear(); + if (!preserveCursors) { + this.cursors.clear(); + this.hydrationFloor = 0n; + } } private async recover(reason: string): Promise { @@ -166,6 +195,7 @@ export class EventMonitor { console.log(`${reason}; restarting market event feed`); await this.stopInternal(true); await this.start(); + await this.reconcileMarkets(this.adapters.flatMap(adapter => adapter.addresses())); } finally { this.reconnecting = false; } diff --git a/src/runtime/protocol-event-adapter.ts b/src/runtime/protocol-event-adapter.ts index 088338a..585bf15 100644 --- a/src/runtime/protocol-event-adapter.ts +++ b/src/runtime/protocol-event-adapter.ts @@ -1,7 +1,9 @@ -import { type PublicClient } from 'viem'; +import { type Address, type PublicClient } from 'viem'; export interface ProtocolEventAdapter { readonly id: string; + addresses(): readonly Address[]; + owns(address: Address): boolean; watch( client: PublicClient, onLogs: (logs: any[]) => void | Promise, @@ -9,6 +11,7 @@ export interface ProtocolEventAdapter { ): Promise void | Promise>>; bufferKey(log: any): string | null; reconcile(logs: readonly any[]): Promise; + reconcileAddresses(addresses: readonly Address[]): Promise; apply(logs: any[]): Promise; clear?(): void; } diff --git a/test/carbon-mixed-strategy.test.ts b/test/carbon-mixed-strategy.test.ts index f1f7bac..b0cd59b 100644 --- a/test/carbon-mixed-strategy.test.ts +++ b/test/carbon-mixed-strategy.test.ts @@ -36,6 +36,9 @@ test("finds a profitable mixed Carbon and V2 route", () => { fee: 30, reserve0: 10n ** 30n, reserve1: 10n ** 24n, + variant: 'uniswap-v2', + scale0: 1n, + scale1: 1n, } satisfies PairInfo); engine.setCarbonStrategies([carbonStrategy()]); @@ -68,6 +71,9 @@ test("finds a profitable mixed Carbon, V2, and V3 route", () => { fee: 30, reserve0: 10n ** 24n, reserve1: 10n ** 30n, + variant: 'uniswap-v2', + scale0: 1n, + scale1: 1n, } satisfies PairInfo); addV3Pool(engine, address(5), TOKENS[2].address, tokenA); engine.setCarbonStrategies([carbonStrategy()]); diff --git a/test/execution-lock.test.ts b/test/execution-lock.test.ts index 5ce8af5..fc75a98 100644 --- a/test/execution-lock.test.ts +++ b/test/execution-lock.test.ts @@ -32,3 +32,19 @@ test("locks pools before an overlapping fire-and-forget submission", async () => releaseFirst(); await first; }); + +test("submits the refreshed opportunity", async () => { + let submittedProfit = 0n; + const manager = new OpportunityManager( + {} as never, + async (_graph, refreshed) => { + submittedProfit = refreshed.profit; + return true; + }, + async stale => ({ ...stale, profit: 2n }) + ); + + await manager.processOpportunities({} as never, [opportunity]); + + expect(submittedProfit).toBe(2n); +}); diff --git a/test/market-db.test.ts b/test/market-db.test.ts index bc0a0c4..6d81f3d 100644 --- a/test/market-db.test.ts +++ b/test/market-db.test.ts @@ -20,6 +20,9 @@ describe('market catalog', () => { token1, fee: 30, factory: 'test-v2', + variant: 'solidly-stable', + scale0: 1_000_000n, + scale1: 1_000_000n, }], v3Pools: [{ name: 'test-v3', diff --git a/test/market-filter.test.ts b/test/market-filter.test.ts index 4e46a61..a33a3d0 100644 --- a/test/market-filter.test.ts +++ b/test/market-filter.test.ts @@ -18,6 +18,9 @@ describe("filterDiscoveredMarkets", () => { token1: tokenA, fee: 30, factory: "v2", + variant: 'uniswap-v2', + scale0: 1n, + scale1: 1n, }], [{ name: "v3", @@ -44,6 +47,9 @@ describe("filterDiscoveredMarkets", () => { token1: tokenA, fee: 30, factory: "v2", + variant: 'uniswap-v2', + scale0: 1n, + scale1: 1n, }], [{ name: "v3", diff --git a/test/unified-graph.stress.test.ts b/test/unified-graph.stress.test.ts index c8b9831..81926dc 100644 --- a/test/unified-graph.stress.test.ts +++ b/test/unified-graph.stress.test.ts @@ -52,6 +52,9 @@ function pair( reserve0, reserve1, fee, + variant: 'uniswap-v2', + scale0: 1n, + scale1: 1n, }; } diff --git a/test/v2-metadata.test.ts b/test/v2-metadata.test.ts new file mode 100644 index 0000000..44ecce2 --- /dev/null +++ b/test/v2-metadata.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from 'bun:test'; +import { type Address } from 'viem'; +import { discoverV2PoolMetadata } from '../src/protocols/v2/metadata'; + +const address = (value: number) => `0x${value.toString(16).padStart(40, '0')}` as Address; + +test('V2 discovery splits a reverted range without using the Solidly filter for Dragon', async () => { + const ranges: Array<[number, number]> = []; + let filterCalls = 0; + const client = { + async readContract({ functionName, args }: any): Promise { + if (functionName === 'getPairsLength') return [4n, 0n]; + if (functionName === 'filterVolatileHermesPairs') { + filterCalls++; + return []; + } + if (functionName === 'getPairsByIndexRange') { + const start = Number(args[1]); + const stop = Number(args[2]); + ranges.push([start, stop]); + if (stop - start > 2) throw new Error('execution reverted'); + return Array.from({ length: stop - start }, (_, index) => [ + address(100 + start + index), + address(200 + start + index), + address(300 + start + index), + ]); + } + throw new Error(`Unexpected ${functionName}`); + }, + }; + + const pools = await discoverV2PoolMetadata(client); + expect(pools).toHaveLength(4); + expect(ranges).toEqual([[0, 4], [0, 2], [2, 4]]); + expect(filterCalls).toBe(0); +}); + +test('Solidly discovery reads factory fees once and persists stable metadata', async () => { + const feeCalls: boolean[] = []; + const client = { + async readContract({ functionName, args }: any): Promise { + if (functionName === 'getPairsLength') return [0n, 2n]; + if (functionName === 'getPairsByIndexRange') return [ + [address(1), address(2), address(11)], + [address(3), address(4), address(12)], + ]; + if (functionName === 'filterVolatileHermesPairs') return [true, false]; + if (functionName === 'getFee') { + if (args.length !== 1) throw new Error('wrong getFee ABI'); + feeCalls.push(args[0]); + return args[0] ? 4n : 18n; + } + if (functionName === 'metadata') return { + scale0: 1_000_000n, + scale1: 1_000_000n, + reserve0: 1n, + reserve1: 1n, + stable: true, + token0: address(1), + token1: address(2), + }; + throw new Error(`Unexpected ${functionName}`); + }, + }; + + const pools = await discoverV2PoolMetadata(client); + expect(feeCalls).toEqual([true, false]); + expect(pools.map(pool => [pool.variant, pool.fee, pool.scale0])).toEqual([ + ['solidly-stable', 4, 1_000_000n], + ['solidly-volatile', 18, 1n], + ]); +}); diff --git a/test/v2.stress.test.ts b/test/v2.stress.test.ts index a4e88b1..29b4abd 100644 --- a/test/v2.stress.test.ts +++ b/test/v2.stress.test.ts @@ -47,6 +47,9 @@ function pair( reserve0, reserve1, fee, + variant: 'uniswap-v2', + scale0: 1n, + scale1: 1n, }; } diff --git a/test/v2.test.ts b/test/v2.test.ts index cc1aa7c..cf671a1 100644 --- a/test/v2.test.ts +++ b/test/v2.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test"; import { type Address } from "viem"; import { TOKENS } from "../src/constants"; -import { swapV2 } from "../src/protocols/v2/quote"; +import { swapSolidlyStable, swapV2 } from "../src/protocols/v2/quote"; +import { encodeV2RouteData } from "../src/protocols/v2/execution"; import { type PairInfo } from "../src/protocols/v2/types"; import { type ArbitrageSearchPolicy } from "../src/market-graph/types"; import { OpportunityEngine } from "../src/opportunities/opportunity-engine"; @@ -32,6 +33,9 @@ function pair( reserve0, reserve1, fee, + variant: 'uniswap-v2', + scale0: 1n, + scale1: 1n, }; } @@ -52,7 +56,36 @@ test("swapV2 matches the Solidity formula without early rounding", () => { )).toBe(26036525510536776183643n); }); +test("stable V2 quote matches the historical Yaka pool output", () => { + expect(swapSolidlyStable(12_271_683n, { + variant: 'solidly-stable', + reserveIn: 21_501_234n, + reserveOut: 149_089_569n, + scaleIn: 1_000_000n, + scaleOut: 1_000_000n, + fee: 4, + })).toBe(22_886_491n); + expect(encodeV2RouteData('solidly-stable')).toBe('0x01'); + expect(encodeV2RouteData('solidly-volatile')).toBe('0x'); +}); + describe("V2 arbitrage graph", () => { + test("keeps stable pools in routes and marks their execution mode", () => { + const stable = pair(1, tokenA, tokenB, tokenAmount("1000"), tokenAmount("2200"), 4); + stable.variant = 'solidly-stable'; + stable.scale0 = 10n ** 18n; + stable.scale1 = 10n ** 18n; + const graph = buildGraph([ + stable, + pair(2, tokenB, tokenC, tokenAmount("1000"), tokenAmount("2200")), + pair(3, tokenC, tokenA, tokenAmount("1000"), tokenAmount("2200")), + ]); + + const opportunity = graph.findOpportunities({ startTokens: [tokenA] })[0]; + expect(opportunity.routeData).toEqual(['0x01', '0x', '0x']); + expect(graph.findBestFlashPoolForToken(tokenA, 1n, [pairAddress(2), pairAddress(3)])).toBeNull(); + }); + test("finds a profitable three-pool circular arbitrage route", () => { const graph = buildGraph([ pair(1, tokenA, tokenB, tokenAmount("1000"), tokenAmount("2200")), diff --git a/test/v3-events.test.ts b/test/v3-events.test.ts index d4cf143..9ecf79d 100644 --- a/test/v3-events.test.ts +++ b/test/v3-events.test.ts @@ -30,7 +30,10 @@ describe("EventMonitor V3 pool events", () => { const pairAddress = "0x0000000000000000000000000000000000000a22" as Address; const feed = fakeEventClient([[200n, 201n, BigInt(Math.floor(Date.now() / 1000))]]); const monitor = new EventMonitor({ client: feed.client }, [ - new V2EventAdapter(feed.client, graph, [{ pairAddress, token0, token1, fee: 30, factory: '' }], async () => {}), + new V2EventAdapter(feed.client, graph, [{ + pairAddress, token0, token1, fee: 30, factory: '', + variant: 'uniswap-v2', scale0: 1n, scale1: 1n, + }], async () => {}), ]); await monitor.startBuffering(); @@ -62,6 +65,9 @@ describe("EventMonitor V3 pool events", () => { token1, fee: 30, factory: '', + variant: 'uniswap-v2', + scale0: 1n, + scale1: 1n, }], async () => {}), ]); await monitor.start(); @@ -79,6 +85,55 @@ describe("EventMonitor V3 pool events", () => { await monitor.stop(); }); + test("rejects logs older than the hydration floor", async () => { + const graph = new OpportunityEngine(ARBITRAGE_SEARCH_POLICY, []); + const pairAddress = "0x0000000000000000000000000000000000000a22" as Address; + const feed = fakeEventClient(); + graph.addPair({ + pairAddress, token0, token1, reserve0: 200n, reserve1: 201n, fee: 30, + variant: "uniswap-v2", scale0: 1n, scale1: 1n, + }); + const monitor = new EventMonitor({ client: feed.client }, [ + new V2EventAdapter(feed.client, graph, [{ + pairAddress, token0, token1, fee: 30, factory: "", + variant: "uniswap-v2", scale0: 1n, scale1: 1n, + }], async () => {}), + ]); + + await monitor.startBuffering(); + await monitor.activate(2n); + await feed.emit([syncLog(pairAddress, 0, 100n, 101n, 1n)]); + expect(graph.getAllPairs()[0]).toMatchObject({ reserve0: 200n, reserve1: 201n }); + + await feed.emit([syncLog(pairAddress, 0, 300n, 301n, 3n)]); + expect(graph.getAllPairs()[0]).toMatchObject({ reserve0: 300n, reserve1: 301n }); + await monitor.stop(); + }); + + test("reconciles selected markets and versions them at the current head", async () => { + const graph = new OpportunityEngine(ARBITRAGE_SEARCH_POLICY, []); + const pairAddress = "0x0000000000000000000000000000000000000a22" as Address; + const feed = fakeEventClient([[300n, 301n, 1n]], 10n); + graph.addPair({ + pairAddress, token0, token1, reserve0: 100n, reserve1: 101n, fee: 30, + variant: "uniswap-v2", scale0: 1n, scale1: 1n, + }); + const monitor = new EventMonitor({ client: feed.client }, [ + new V2EventAdapter(feed.client, graph, [{ + pairAddress, token0, token1, fee: 30, factory: "", + variant: "uniswap-v2", scale0: 1n, scale1: 1n, + }], async () => {}), + ]); + + await monitor.start(); + await monitor.reconcileMarkets([pairAddress]); + expect(graph.getAllPairs()[0]).toMatchObject({ reserve0: 300n, reserve1: 301n }); + + await feed.emit([syncLog(pairAddress, 0, 200n, 201n, 10n)]); + expect(graph.getAllPairs()[0]).toMatchObject({ reserve0: 300n, reserve1: 301n }); + await monitor.stop(); + }); + test("applies mixed V3 logs in chain order before checking arbitrage", async () => { const graph = new OpportunityEngine( ARBITRAGE_SEARCH_POLICY, @@ -151,7 +206,7 @@ describe("EventMonitor V3 pool events", () => { }); }); -function fakeEventClient(readResult?: unknown): { client: any; emit(logs: any[]): Promise } { +function fakeEventClient(readResult?: unknown, blockNumber = 1n): { client: any; emit(logs: any[]): Promise } { let onLogs: ((logs: any[]) => void | Promise) | undefined; return { client: { @@ -160,6 +215,7 @@ function fakeEventClient(readResult?: unknown): { client: any; emit(logs: any[]) return () => {}; }, readContract: async () => readResult, + getBlockNumber: async () => blockNumber, }, async emit(logs) { if (!onLogs) throw new Error('event monitor is not started'); diff --git a/test/v3-strategy.test.ts b/test/v3-strategy.test.ts index 8929758..8d6374c 100644 --- a/test/v3-strategy.test.ts +++ b/test/v3-strategy.test.ts @@ -45,6 +45,9 @@ function pair( reserve0, reserve1, fee, + variant: 'uniswap-v2', + scale0: 1n, + scale1: 1n, }; } From b42ac57af831d87f2f0f3bef12efe6548ba2d21e Mon Sep 17 00:00:00 2001 From: RedWilly Date: Wed, 15 Jul 2026 10:02:56 +0000 Subject: [PATCH 2/2] refactor: improve cursor management and hydration floor checks in EventMonitor - updateCursorForAddress() as the single cursor key, ordering, and update path. - Added isAtOrBelowHydrationFloor() for both hydration checks. - Removed markApplied(), markReconciled(), and cursorKey(). --- src/runtime/event-monitor.ts | 50 +++++++++++++++++------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/src/runtime/event-monitor.ts b/src/runtime/event-monitor.ts index cff4f98..2071470 100644 --- a/src/runtime/event-monitor.ts +++ b/src/runtime/event-monitor.ts @@ -71,8 +71,11 @@ export class EventMonitor { this.buffered.clear(); const byAdapter = new Map(); for (const entry of entries) { - if (chainLogBlockNumber(entry.log) <= this.hydrationFloor) continue; - this.markApplied(entry.adapter, entry.log); + if (this.isAtOrBelowHydrationFloor(entry.log)) continue; + const address = entry.log.address as Address | undefined; + if (address) { + this.updateCursorForAddress(entry.adapter, address, advanceCursor(undefined, entry.log)); + } const logs = byAdapter.get(entry.adapter); if (logs) logs.push(entry.log); else byAdapter.set(entry.adapter, [entry.log]); @@ -92,7 +95,12 @@ export class EventMonitor { const owned = addresses.filter(address => adapter.owns(address)); if (owned.length === 0) return; await adapter.reconcileAddresses(owned); - for (const address of owned) this.markReconciled(adapter, address, blockNumber); + const reconciled = { + blockNumber, + transactionIndex: Number.MAX_SAFE_INTEGER, + logIndex: Number.MAX_SAFE_INTEGER, + }; + for (const address of owned) this.updateCursorForAddress(adapter, address, reconciled); })); return blockNumber; } @@ -127,39 +135,29 @@ export class EventMonitor { private freshLogs(adapter: ProtocolEventAdapter, logs: any[]): any[] { let count = 0; for (const log of logs) { - if (chainLogBlockNumber(log) <= this.hydrationFloor) continue; - const key = this.cursorKey(adapter, log); - if (!key) continue; - const cursor = this.cursors.get(key); - if (cursor && !isLogAfterCursor(log, cursor)) continue; - this.cursors.set(key, advanceCursor(cursor, log)); + if (this.isAtOrBelowHydrationFloor(log)) continue; + const address = log.address as Address | undefined; + if (!address || !this.updateCursorForAddress(adapter, address, advanceCursor(undefined, log))) continue; logs[count++] = log; } logs.length = count; return logs; } - private markApplied(adapter: ProtocolEventAdapter, log: any): void { - const key = this.cursorKey(adapter, log); - if (!key) return; - const cursor = this.cursors.get(key); - if (!cursor || isLogAfterCursor(log, cursor)) this.cursors.set(key, advanceCursor(cursor, log)); - } - - private cursorKey(adapter: ProtocolEventAdapter, log: any): string | null { - const address = log.address?.toLowerCase(); - return address ? `${adapter.id}:${address}` : null; + private isAtOrBelowHydrationFloor(log: any): boolean { + return chainLogBlockNumber(log) <= this.hydrationFloor; } - private markReconciled(adapter: ProtocolEventAdapter, address: Address, blockNumber: bigint): void { + private updateCursorForAddress( + adapter: ProtocolEventAdapter, + address: Address, + next: ChainCursor + ): boolean { const key = `${adapter.id}:${address.toLowerCase()}`; const cursor = this.cursors.get(key); - const reconciled = { - blockNumber, - transactionIndex: Number.MAX_SAFE_INTEGER, - logIndex: Number.MAX_SAFE_INTEGER, - }; - if (!cursor || cursor.blockNumber <= blockNumber) this.cursors.set(key, reconciled); + if (cursor && !isLogAfterCursor(next, cursor)) return false; + this.cursors.set(key, next); + return true; } private async stopInternal(preserveCursors: boolean): Promise {