├── tests ├── __init__.py ├── curve │ └── __init__.py ├── fork │ ├── __init__.py │ └── test_anvil_fork.py ├── arbitrage │ ├── __init__.py │ └── test_uniswap_curve_cycle.py ├── manager │ ├── __init__.py │ └── test_erc20tokenmanager.py ├── uniswap │ ├── __init__.py │ ├── v2 │ │ ├── __init__.py │ │ └── test_uniswap_v2_functions.py │ ├── v3 │ │ ├── __init__.py │ │ ├── libraries │ │ │ ├── __init__.py │ │ │ ├── test_functions.py │ │ │ ├── test_bit_math.py │ │ │ ├── test_liquidity_math.py │ │ │ ├── test_tick.py │ │ │ ├── test_tick_math.py │ │ │ ├── test_full_math.py │ │ │ ├── test_swap_math.py │ │ │ ├── test_tick_bitmap.py │ │ │ └── test_sqrt_price_math.py │ │ ├── empty_v3_liquidity_snapshot.json │ │ ├── test_uniswap_v3_dataclasses.py │ │ └── test_uniswap_v3_functions.py │ └── test_uniswap_managers.py ├── transaction │ └── __init__.py ├── test_chainlink_price_feed.py ├── test_config.py ├── test_registry.py ├── conftest.py ├── test_functions.py ├── test_erc20_token.py └── test_builder_endpoint.py ├── src └── degenbot │ ├── py.typed │ ├── dex │ ├── __init__.py │ └── uniswap.py │ ├── curve │ ├── __init__.py │ └── curve_stableswap_dataclasses.py │ ├── fork │ ├── __init__.py │ └── anvil_fork.py │ ├── transaction │ ├── __init__.py │ └── simulation_ledger.py │ ├── registry │ ├── __init__.py │ ├── all_tokens.py │ └── all_pools.py │ ├── uniswap │ ├── v3_libraries │ │ ├── constants.py │ │ ├── unsafe_math.py │ │ ├── __init__.py │ │ ├── tick.py │ │ ├── yul_operations.py │ │ ├── functions.py │ │ ├── liquidity_math.py │ │ ├── full_math.py │ │ ├── bit_math.py │ │ ├── swap_math.py │ │ ├── tick_bitmap.py │ │ ├── sqrt_price_math.py │ │ └── tick_math.py │ ├── __init__.py │ ├── v3_tick_lens.py │ ├── v2_dataclasses.py │ ├── v2_functions.py │ ├── v3_dataclasses.py │ ├── v3_functions.py │ └── v3_snapshot.py │ ├── manager │ ├── __init__.py │ └── token_manager.py │ ├── arbitrage │ ├── __init__.py │ └── arbitrage_dataclasses.py │ ├── logging.py │ ├── config.py │ ├── __init__.py │ ├── constants.py │ ├── functions.py │ ├── exceptions.py │ ├── baseclasses.py │ └── chainlink.py ├── LICENSE ├── pyproject.toml ├── .github └── workflows │ └── publish-to-pypi.yaml └── .gitignore /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/degenbot/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/curve/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fork/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/degenbot/dex/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/arbitrage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/manager/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/uniswap/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/uniswap/v2/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/uniswap/v3/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/degenbot/curve/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/transaction/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/uniswap/v3/libraries/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/uniswap/v3/empty_v3_liquidity_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "snapshot_block": 12369620 3 | } -------------------------------------------------------------------------------- /src/degenbot/fork/__init__.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: F401 2 | 3 | 4 | from .anvil_fork import AnvilFork 5 | -------------------------------------------------------------------------------- /src/degenbot/transaction/__init__.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: F401 2 | 3 | 4 | from .uniswap_transaction import UniswapTransaction 5 | -------------------------------------------------------------------------------- /src/degenbot/registry/__init__.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: F401 2 | 3 | 4 | from .all_pools import AllPools 5 | from .all_tokens import AllTokens 6 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v3_libraries/constants.py: -------------------------------------------------------------------------------- 1 | Q96 = 0x1000000000000000000000000 2 | Q96_RESOLUTION = 96 3 | Q128 = 0x100000000000000000000000000000000 4 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v3_libraries/unsafe_math.py: -------------------------------------------------------------------------------- 1 | from . import yul_operations as yul 2 | 3 | 4 | def divRoundingUp(x: int, y: int) -> int: 5 | return yul.add(yul.div(x, y), yul.gt(yul.mod(x, y), 0)) 6 | -------------------------------------------------------------------------------- /src/degenbot/manager/__init__.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: F401 2 | 3 | 4 | from ..registry.all_pools import AllPools 5 | from ..registry.all_tokens import AllTokens 6 | from .token_manager import Erc20TokenHelperManager 7 | -------------------------------------------------------------------------------- /src/degenbot/arbitrage/__init__.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: F401 2 | from .arbitrage_dataclasses import ArbitrageCalculationResult 3 | from .uniswap_curve_cycle import UniswapCurveCycle 4 | from .uniswap_lp_cycle import UniswapLpCycle 5 | -------------------------------------------------------------------------------- /src/degenbot/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | """ 4 | Create a shared logger instance for this module. Set default level to INFO and send to stdout with StreamHandler 5 | """ 6 | 7 | logger = logging.getLogger("degenbot") 8 | logger.propagate = False 9 | logger.setLevel(logging.INFO) 10 | logger.addHandler(logging.StreamHandler()) 11 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v3_libraries/__init__.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: F401 2 | 3 | 4 | from . import bit_math as BitMath 5 | from . import constants 6 | from . import full_math as FullMath 7 | from . import liquidity_math as LiquidityMath 8 | from . import sqrt_price_math as SqrtPriceMath 9 | from . import swap_math as SwapMath 10 | from . import tick as Tick 11 | from . import tick_bitmap as TickBitmap 12 | from . import tick_math as TickMath 13 | from . import unsafe_math as UnsafeMath 14 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v3_libraries/tick.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from ...constants import MAX_UINT128 4 | from . import tick_math as TickMath 5 | 6 | 7 | def tickSpacingToMaxLiquidityPerTick(tickSpacing: int) -> int: 8 | minTick = Decimal(TickMath.MIN_TICK) // tickSpacing * tickSpacing 9 | maxTick = Decimal(TickMath.MAX_TICK) // tickSpacing * tickSpacing 10 | numTicks = ((maxTick - minTick) // tickSpacing) + 1 11 | return round(MAX_UINT128 // numTicks) 12 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/__init__.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: F401 2 | 3 | 4 | from .v2_dataclasses import UniswapV2PoolSimulationResult, UniswapV2PoolState 5 | from .v2_liquidity_pool import LiquidityPool 6 | from .v3_dataclasses import ( 7 | UniswapV3BitmapAtWord, 8 | UniswapV3LiquidityAtTick, 9 | UniswapV3LiquidityEvent, 10 | UniswapV3PoolExternalUpdate, 11 | UniswapV3PoolSimulationResult, 12 | UniswapV3PoolState, 13 | ) 14 | from .v3_liquidity_pool import V3LiquidityPool 15 | from .v3_snapshot import UniswapV3LiquiditySnapshot 16 | from .v3_tick_lens import TickLens 17 | -------------------------------------------------------------------------------- /src/degenbot/config.py: -------------------------------------------------------------------------------- 1 | from web3 import Web3 2 | 3 | from .exceptions import DegenbotError 4 | from .logging import logger 5 | 6 | _web3: Web3 7 | 8 | 9 | def get_web3() -> Web3: 10 | try: 11 | return _web3 12 | except NameError: 13 | raise DegenbotError("A Web3 instance has not been provided.") from None 14 | 15 | 16 | def set_web3(w3: Web3) -> None: 17 | if w3.is_connected() is False: 18 | raise DegenbotError("Web3 object is not connected.") 19 | 20 | logger.info(f"Connected to Web3 provider {w3.provider}") 21 | 22 | global _web3 23 | _web3 = w3 24 | -------------------------------------------------------------------------------- /tests/test_chainlink_price_feed.py: -------------------------------------------------------------------------------- 1 | from degenbot.chainlink import ChainlinkPriceContract 2 | from degenbot.config import set_web3 3 | from eth_utils.address import to_checksum_address 4 | 5 | 6 | def test_chainlink_feed(ethereum_full_node_web3): 7 | set_web3(ethereum_full_node_web3) 8 | 9 | # Load WETH price feed 10 | # ref: https://data.chain.link/ethereum/mainnet/crypto-usd/eth-usd 11 | weth_price_feed = ChainlinkPriceContract( 12 | to_checksum_address("0x5f4ec3df9cbd43714fe2740f5e3616155c5b8419") 13 | ) 14 | assert isinstance(weth_price_feed.update_price(), float) 15 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v3_libraries/yul_operations.py: -------------------------------------------------------------------------------- 1 | def gt(x: int, y: int) -> int: 2 | return 1 if x > y else 0 3 | 4 | 5 | def mod(x: int, y: int) -> int: 6 | return 0 if y == 0 else x % y 7 | 8 | 9 | def mul(x: int, y: int) -> int: 10 | return x * y 11 | 12 | 13 | def shl(x: int, y: int) -> int: 14 | return y << x 15 | 16 | 17 | def shr(x: int, y: int) -> int: 18 | return y >> x 19 | 20 | 21 | def or_(x: int, y: int) -> int: 22 | return x | y 23 | 24 | 25 | def add(x: int, y: int) -> int: 26 | return x + y 27 | 28 | 29 | def div(x: int, y: int) -> int: 30 | return 0 if y == 0 else x // y 31 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import degenbot 2 | import pytest 3 | import web3 4 | from degenbot.config import set_web3 5 | from degenbot.erc20_token import Erc20Token 6 | from degenbot.exceptions import DegenbotError 7 | 8 | 9 | def test_disconnected_web3(): 10 | w3 = web3.Web3(web3.HTTPProvider("https://google.com")) 11 | with pytest.raises(DegenbotError, match="Web3 object is not connected."): 12 | set_web3(w3) 13 | 14 | 15 | def test_unset_web3(): 16 | WETH_ADDRESS = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" 17 | 18 | del degenbot.config._web3 19 | 20 | with pytest.raises(DegenbotError, match="A Web3 instance has not been provided."): 21 | Erc20Token(WETH_ADDRESS) 22 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v3_tick_lens.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | 3 | from eth_typing import ChecksumAddress 4 | from eth_utils.address import to_checksum_address 5 | from web3.contract.contract import Contract 6 | 7 | from .. import config 8 | from .abi import UNISWAP_V3_TICKLENS_ABI 9 | 10 | 11 | class TickLens: 12 | def __init__( 13 | self, 14 | address: ChecksumAddress | str, 15 | abi: List[Any] | None = None, 16 | ): 17 | self.address = to_checksum_address(address) 18 | self.abi = abi if abi is not None else UNISWAP_V3_TICKLENS_ABI 19 | 20 | @property 21 | def _w3_contract(self) -> Contract: 22 | return config.get_web3().eth.contract( 23 | address=self.address, 24 | abi=self.abi, 25 | ) 26 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v3_libraries/functions.py: -------------------------------------------------------------------------------- 1 | from ...constants import MAX_INT128, MAX_INT256, MAX_UINT160, MIN_INT128, MIN_INT256 2 | from ...exceptions import EVMRevertError 3 | 4 | 5 | def mulmod(x: int, y: int, k: int) -> int: 6 | if k == 0: 7 | raise EVMRevertError 8 | return (x * y) % k 9 | 10 | 11 | # adapted from OpenZeppelin's overflow checks, which throw 12 | # an exception if the input value exceeds the maximum value 13 | # for this type 14 | def to_int128(x: int) -> int: 15 | if not (MIN_INT128 <= x <= MAX_INT128): 16 | raise EVMRevertError(f"{x} outside range of int128 values") 17 | return x 18 | 19 | 20 | def to_int256(x: int) -> int: 21 | if not (MIN_INT256 <= x <= MAX_INT256): 22 | raise EVMRevertError(f"{x} outside range of int256 values") 23 | return x 24 | 25 | 26 | def to_uint160(x: int) -> int: 27 | if x > MAX_UINT160: 28 | raise EVMRevertError(f"{x} greater than maximum uint160 value") 29 | return x 30 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v3_libraries/liquidity_math.py: -------------------------------------------------------------------------------- 1 | from ...constants import MAX_INT128, MAX_UINT128, MIN_INT128, MIN_UINT128 2 | from ...exceptions import EVMRevertError 3 | 4 | 5 | def addDelta(x: int, y: int) -> int: 6 | """ 7 | This function has been heavily modified to directly check that the result 8 | fits in a uint128, instead of checking via < or >= tricks via Solidity's 9 | built-in casting as implemented at https://github.com/Uniswap/v3-core/blob/main/contracts/libraries/LiquidityMath.sol 10 | """ 11 | 12 | if not (MIN_UINT128 <= x <= MAX_UINT128): 13 | raise EVMRevertError("x not a valid uint128") 14 | if not (MIN_INT128 <= y <= MAX_INT128): 15 | raise EVMRevertError("y not a valid int128") 16 | 17 | z = x + y 18 | 19 | if y < 0 and not (MIN_UINT128 <= z <= MAX_UINT128): 20 | raise EVMRevertError("LS") 21 | elif not (MIN_UINT128 <= z <= MAX_UINT128): 22 | raise EVMRevertError("LA") 23 | 24 | return z 25 | -------------------------------------------------------------------------------- /tests/uniswap/v3/libraries/test_functions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from degenbot.exceptions import EVMRevertError 3 | from degenbot.uniswap.v3_libraries import functions 4 | from degenbot.constants import MAX_INT128, MIN_INT128, MIN_INT256, MAX_INT256 5 | 6 | 7 | def test_mulmod(): 8 | with pytest.raises(EVMRevertError): 9 | functions.mulmod(1, 2, 0) 10 | 11 | for x in range(1, 10): 12 | for y in range(1, 10): 13 | for z in range(1, 10): 14 | functions.mulmod(x, y, z) 15 | 16 | 17 | def test_to_int(): 18 | functions.to_int128(MIN_INT128) 19 | functions.to_int128(MAX_INT128) 20 | 21 | functions.to_int256(MIN_INT256) 22 | functions.to_int256(MAX_INT256) 23 | 24 | with pytest.raises(EVMRevertError): 25 | functions.to_int128(MIN_INT256) 26 | 27 | with pytest.raises(EVMRevertError): 28 | functions.to_int128(MAX_INT256) 29 | 30 | with pytest.raises(EVMRevertError): 31 | functions.to_int256(MIN_INT256 - 1) 32 | 33 | with pytest.raises(EVMRevertError): 34 | functions.to_int256(MAX_INT256 + 1) 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2022] [BowTiedDevil] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /tests/uniswap/v3/libraries/test_bit_math.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from degenbot.exceptions import EVMRevertError 3 | from degenbot.uniswap.v3_libraries import bit_math as BitMath 4 | 5 | # Tests adapted from Typescript tests on Uniswap V3 Github repo 6 | # ref: https://github.com/Uniswap/v3-core/blob/main/test/BitMath.spec.ts 7 | 8 | 9 | def test_mostSignificantBit(): 10 | with pytest.raises(EVMRevertError): 11 | # this test should fail 12 | BitMath.mostSignificantBit(0) 13 | 14 | assert BitMath.mostSignificantBit(1) == 0 15 | 16 | assert BitMath.mostSignificantBit(2) == 1 17 | 18 | for i in range(256): 19 | # test all powers of 2 20 | assert BitMath.mostSignificantBit(2**i) == i 21 | assert BitMath.mostSignificantBit(2**256 - 1) == 255 22 | 23 | 24 | def test_leastSignificantBit(): 25 | with pytest.raises(EVMRevertError): 26 | # this test should fail 27 | BitMath.leastSignificantBit(0) 28 | 29 | assert BitMath.leastSignificantBit(1) == 0 30 | 31 | assert BitMath.leastSignificantBit(2) == 1 32 | 33 | for i in range(256): 34 | # test all powers of 2 35 | assert BitMath.leastSignificantBit(2**i) == i 36 | 37 | assert BitMath.leastSignificantBit(2**256 - 1) == 0 38 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v2_dataclasses.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | 3 | from eth_typing import ChecksumAddress 4 | 5 | from ..baseclasses import BasePoolState, Message, UniswapSimulationResult 6 | 7 | 8 | @dataclasses.dataclass(slots=True, frozen=True) 9 | class UniswapV2PoolState(BasePoolState): 10 | pool: ChecksumAddress 11 | reserves_token0: int 12 | reserves_token1: int 13 | 14 | def copy(self) -> "UniswapV2PoolState": 15 | return UniswapV2PoolState( 16 | pool=self.pool, 17 | reserves_token0=self.reserves_token0, 18 | reserves_token1=self.reserves_token1, 19 | ) 20 | 21 | 22 | @dataclasses.dataclass(slots=True, frozen=True) 23 | class UniswapV2PoolSimulationResult(UniswapSimulationResult): 24 | initial_state: UniswapV2PoolState 25 | final_state: UniswapV2PoolState 26 | 27 | 28 | @dataclasses.dataclass(slots=True, eq=False) 29 | class UniswapV2PoolExternalUpdate: 30 | block_number: int = dataclasses.field(compare=False) 31 | reserves_token0: int 32 | reserves_token1: int 33 | tx: str | None = dataclasses.field(compare=False, default=None) 34 | 35 | 36 | @dataclasses.dataclass(slots=True, frozen=True) 37 | class UniswapV2PoolStateUpdated(Message): 38 | state: UniswapV2PoolState 39 | -------------------------------------------------------------------------------- /tests/uniswap/v3/test_uniswap_v3_dataclasses.py: -------------------------------------------------------------------------------- 1 | from degenbot.uniswap.v3_dataclasses import UniswapV3BitmapAtWord, UniswapV3LiquidityAtTick 2 | 3 | 4 | def test_tick_bitmap() -> None: 5 | # Test equality 6 | assert UniswapV3BitmapAtWord(bitmap=0) == UniswapV3BitmapAtWord(bitmap=0) 7 | 8 | # Test uniqueness 9 | assert UniswapV3BitmapAtWord(bitmap=0) is not UniswapV3BitmapAtWord(bitmap=0) 10 | 11 | # Test dict export method 12 | assert UniswapV3BitmapAtWord(bitmap=0).to_dict() == {"bitmap": 0, "block": None} 13 | 14 | 15 | def test_liquidity_data() -> None: 16 | # Test equality 17 | assert UniswapV3LiquidityAtTick( 18 | liquidityNet=80064092962998, liquidityGross=80064092962998 19 | ) == UniswapV3LiquidityAtTick(liquidityNet=80064092962998, liquidityGross=80064092962998) 20 | 21 | # Test uniqueness 22 | assert UniswapV3LiquidityAtTick( 23 | liquidityNet=80064092962998, liquidityGross=80064092962998 24 | ) is not UniswapV3LiquidityAtTick(liquidityNet=80064092962998, liquidityGross=80064092962998) 25 | 26 | # Test dict export method 27 | assert UniswapV3LiquidityAtTick( 28 | liquidityNet=80064092962998, liquidityGross=80064092962998 29 | ).to_dict() == {"liquidityNet": 80064092962998, "liquidityGross": 80064092962998, "block": None} 30 | -------------------------------------------------------------------------------- /tests/uniswap/v2/test_uniswap_v2_functions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from degenbot.uniswap.v2_functions import generate_v2_pool_address 4 | 5 | 6 | def test_v2_address_generator(): 7 | # Should generate address for Uniswap V2 WETH/WBTC pool 8 | # factory ref: https://etherscan.io/address/0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f 9 | # WETH ref: https://etherscan.io/address/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 10 | # WBTC ref: https://etherscan.io/address/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599 11 | # pool ref: https://etherscan.io/address/0xBb2b8038a1640196FbE3e38816F3e67Cba72D940 12 | wbtc_weth_address = generate_v2_pool_address( 13 | token_addresses=[ 14 | "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", 15 | "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", 16 | ], 17 | factory_address="0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f", 18 | init_hash="0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f", 19 | ) 20 | 21 | assert wbtc_weth_address == "0xBb2b8038a1640196FbE3e38816F3e67Cba72D940" 22 | 23 | # address generator returns a checksum address, so check against the lowered string 24 | with pytest.raises(AssertionError): 25 | assert wbtc_weth_address == "0xBb2b8038a1640196FbE3e38816F3e67Cba72D940".lower() 26 | -------------------------------------------------------------------------------- /src/degenbot/curve/curve_stableswap_dataclasses.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from typing import List 3 | 4 | from eth_typing import ChecksumAddress, HexAddress 5 | 6 | from ..baseclasses import BasePoolState, Message 7 | 8 | 9 | @dataclasses.dataclass(slots=True, frozen=True) 10 | class CurveStableswapPoolState(BasePoolState): 11 | pool: ChecksumAddress 12 | balances: List[int] 13 | base: "CurveStableswapPoolState | None" = dataclasses.field(default=None) 14 | 15 | 16 | @dataclasses.dataclass(slots=True, frozen=True) 17 | class CurveStableswapPoolSimulationResult: 18 | amount0_delta: int 19 | amount1_delta: int 20 | current_state: CurveStableswapPoolState 21 | future_state: CurveStableswapPoolState 22 | 23 | 24 | @dataclasses.dataclass(slots=True, frozen=True) 25 | class CurveStableSwapPoolAttributes: 26 | address: HexAddress 27 | lp_token_address: HexAddress 28 | coin_addresses: List[HexAddress] 29 | coin_index_type: str 30 | fee: int 31 | admin_fee: int 32 | is_metapool: bool 33 | underlying_coin_addresses: List[HexAddress] | None = dataclasses.field(default=None) 34 | base_pool_address: HexAddress | None = dataclasses.field(default=None) 35 | 36 | 37 | @dataclasses.dataclass(slots=True, frozen=True) 38 | class CurveStableSwapPoolStateUpdated(Message): 39 | state: CurveStableswapPoolState 40 | -------------------------------------------------------------------------------- /src/degenbot/arbitrage/arbitrage_dataclasses.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from typing import Any, List, Tuple 3 | 4 | from ..erc20_token import Erc20Token 5 | 6 | 7 | @dataclasses.dataclass(slots=True, frozen=True) 8 | class ArbitrageCalculationResult: 9 | id: str 10 | input_token: Erc20Token 11 | profit_token: Erc20Token 12 | input_amount: int 13 | profit_amount: int 14 | swap_amounts: List[Any] 15 | 16 | 17 | @dataclasses.dataclass(slots=True, frozen=True) 18 | class CurveStableSwapPoolSwapAmounts: 19 | token_in: Erc20Token 20 | token_in_index: int 21 | token_out: Erc20Token 22 | token_out_index: int 23 | amount_in: int 24 | min_amount_out: int 25 | underlying: bool 26 | 27 | 28 | @dataclasses.dataclass(slots=True, frozen=True) 29 | class UniswapPoolSwapVector: 30 | token_in: Erc20Token 31 | token_out: Erc20Token 32 | zero_for_one: bool 33 | 34 | 35 | @dataclasses.dataclass(slots=True, frozen=True) 36 | class UniswapV2PoolSwapAmounts: 37 | amounts: Tuple[int, int] 38 | amounts_in: Tuple[int, int] | None = None 39 | 40 | 41 | @dataclasses.dataclass(slots=True, frozen=True) 42 | class UniswapV3PoolSwapAmounts: 43 | amount_specified: int 44 | zero_for_one: bool 45 | sqrt_price_limit_x96: int 46 | 47 | 48 | @dataclasses.dataclass(slots=True, frozen=True) 49 | class CurveStableSwapPoolVector: 50 | token_in: Erc20Token 51 | token_out: Erc20Token 52 | -------------------------------------------------------------------------------- /src/degenbot/__init__.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: F401 2 | 3 | 4 | from . import exceptions, uniswap 5 | from .arbitrage.arbitrage_dataclasses import ArbitrageCalculationResult 6 | from .arbitrage.uniswap_curve_cycle import UniswapCurveCycle 7 | from .arbitrage.uniswap_lp_cycle import UniswapLpCycle 8 | from .builder_endpoint import BuilderEndpoint 9 | from .chainlink import ChainlinkPriceContract 10 | from .config import get_web3, set_web3 11 | from .curve.curve_stableswap_liquidity_pool import CurveStableswapPool 12 | from .erc20_token import Erc20Token 13 | from .fork.anvil_fork import AnvilFork 14 | from .functions import next_base_fee 15 | from .logging import logger 16 | from .manager.token_manager import Erc20TokenHelperManager 17 | from .registry.all_pools import AllPools 18 | from .registry.all_tokens import AllTokens 19 | from .transaction.uniswap_transaction import UniswapTransaction 20 | from .uniswap.managers import ( 21 | UniswapV2LiquidityPoolManager, 22 | UniswapV3LiquidityPoolManager, 23 | ) 24 | from .uniswap.v2_liquidity_pool import CamelotLiquidityPool, LiquidityPool 25 | from .uniswap.v3_dataclasses import ( 26 | UniswapV3BitmapAtWord, 27 | UniswapV3LiquidityAtTick, 28 | UniswapV3LiquidityEvent, 29 | UniswapV3PoolExternalUpdate, 30 | UniswapV3PoolSimulationResult, 31 | UniswapV3PoolState, 32 | ) 33 | from .uniswap.v3_liquidity_pool import V3LiquidityPool 34 | from .uniswap.v3_snapshot import UniswapV3LiquiditySnapshot 35 | from .uniswap.v3_tick_lens import TickLens 36 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v3_libraries/full_math.py: -------------------------------------------------------------------------------- 1 | from ...constants import MAX_UINT256, MIN_UINT256 2 | from ...exceptions import EVMRevertError 3 | from .functions import mulmod 4 | 5 | 6 | def mulDiv( 7 | a: int, 8 | b: int, 9 | denominator: int, 10 | ) -> int: 11 | """ 12 | The Solidity implementation is designed to calculate a * b / d without risk of overflowing 13 | the intermediate result (maximum of 2**256-1). 14 | 15 | Python does not have this bit depth limitations on integers, 16 | so simply check for exceptional conditions then return the result 17 | """ 18 | 19 | if not (MIN_UINT256 <= a <= MAX_UINT256): 20 | raise EVMRevertError(f"Invalid input, {a} does not fit into uint256") 21 | 22 | if not (MIN_UINT256 <= b <= MAX_UINT256): 23 | raise EVMRevertError(f"Invalid input, {b} does not fit into uint256") 24 | 25 | if denominator == 0: 26 | raise EVMRevertError("DIVISION BY ZERO") 27 | 28 | result = (a * b) // denominator 29 | 30 | if not (MIN_UINT256 <= result <= MAX_UINT256): 31 | raise EVMRevertError("Invalid result, does not fit in uint256") 32 | 33 | return result 34 | 35 | 36 | def mulDivRoundingUp(a: int, b: int, denominator: int) -> int: 37 | result: int = mulDiv(a, b, denominator) 38 | if mulmod(a, b, denominator) > 0: 39 | # must be less than max uint256 since we're rounding up 40 | if not (MIN_UINT256 <= result < MAX_UINT256): 41 | raise EVMRevertError("FAIL!") 42 | result += 1 43 | return result 44 | -------------------------------------------------------------------------------- /tests/uniswap/v3/libraries/test_liquidity_math.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from degenbot.constants import MAX_INT128, MAX_UINT128, MIN_INT128, MIN_UINT128 3 | from degenbot.exceptions import EVMRevertError 4 | from degenbot.uniswap.v3_libraries import LiquidityMath 5 | 6 | # Tests adapted from Typescript tests on Uniswap V3 Github repo 7 | # ref: https://github.com/Uniswap/v3-core/blob/main/test/LiquidityMath.spec.ts 8 | 9 | 10 | def test_addDelta(): 11 | ### ---------------------------------------------------- 12 | ### LiquidityMath tests 13 | ### ---------------------------------------------------- 14 | 15 | assert LiquidityMath.addDelta(1, 0) == 1 16 | assert LiquidityMath.addDelta(1, -1) == 0 17 | assert LiquidityMath.addDelta(1, 1) == 2 18 | 19 | with pytest.raises(EVMRevertError): 20 | LiquidityMath.addDelta(MIN_UINT128 - 1, 0) 21 | 22 | with pytest.raises(EVMRevertError): 23 | LiquidityMath.addDelta(MAX_UINT128 + 1, 0) 24 | 25 | with pytest.raises(EVMRevertError): 26 | LiquidityMath.addDelta(0, MIN_INT128 - 1) 27 | 28 | with pytest.raises(EVMRevertError): 29 | LiquidityMath.addDelta(0, MAX_INT128 + 1) 30 | 31 | with pytest.raises(EVMRevertError, match="LA"): 32 | # 2**128-15 + 15 overflows 33 | LiquidityMath.addDelta(2**128 - 15, 15) 34 | 35 | with pytest.raises(EVMRevertError, match="LS"): 36 | # 0 + -1 underflows 37 | LiquidityMath.addDelta(0, -1) 38 | 39 | with pytest.raises(EVMRevertError, match="LS"): 40 | # 3 + -4 underflows 41 | LiquidityMath.addDelta(3, -4) 42 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v3_libraries/bit_math.py: -------------------------------------------------------------------------------- 1 | from ...exceptions import EVMRevertError 2 | 3 | 4 | def mostSignificantBit(x: int) -> int: 5 | if x <= 0: 6 | raise EVMRevertError("FAIL: x > 0") 7 | 8 | r = 0 9 | 10 | if x >= 0x100000000000000000000000000000000: 11 | x >>= 128 12 | r += 128 13 | 14 | if x >= 0x10000000000000000: 15 | x >>= 64 16 | r += 64 17 | 18 | if x >= 0x100000000: 19 | x >>= 32 20 | r += 32 21 | 22 | if x >= 0x10000: 23 | x >>= 16 24 | r += 16 25 | 26 | if x >= 0x100: 27 | x >>= 8 28 | r += 8 29 | 30 | if x >= 0x10: 31 | x >>= 4 32 | r += 4 33 | 34 | if x >= 0x4: 35 | x >>= 2 36 | r += 2 37 | 38 | if x >= 0x2: 39 | r += 1 40 | 41 | return r 42 | 43 | 44 | def leastSignificantBit(x: int) -> int: 45 | if x <= 0: 46 | raise EVMRevertError("FAIL: x > 0") 47 | 48 | r = 255 49 | if x & 2**128 - 1 > 0: 50 | r -= 128 51 | else: 52 | x >>= 128 53 | 54 | if x & 2**64 - 1 > 0: 55 | r -= 64 56 | else: 57 | x >>= 64 58 | 59 | if x & 2**32 - 1 > 0: 60 | r -= 32 61 | else: 62 | x >>= 32 63 | 64 | if x & 2**16 - 1 > 0: 65 | r -= 16 66 | else: 67 | x >>= 16 68 | 69 | if x & 2**8 - 1 > 0: 70 | r -= 8 71 | else: 72 | x >>= 8 73 | 74 | if x & 0xF > 0: 75 | r -= 4 76 | else: 77 | x >>= 4 78 | 79 | if x & 0x3 > 0: 80 | r -= 2 81 | else: 82 | x >>= 2 83 | 84 | if x & 0x1 > 0: 85 | r -= 1 86 | 87 | return r 88 | -------------------------------------------------------------------------------- /src/degenbot/constants.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from eth_typing import ChecksumAddress 4 | from eth_utils.address import to_checksum_address 5 | 6 | 7 | def _min_uint(_: int) -> int: 8 | return 0 9 | 10 | 11 | def _max_uint(bits: int) -> int: 12 | result: int = 2**bits - 1 13 | return result 14 | 15 | 16 | def _min_int(bits: int) -> int: 17 | result: int = -(2 ** (bits - 1)) 18 | return result 19 | 20 | 21 | def _max_int(bits: int) -> int: 22 | result: int = (2 ** (bits - 1)) - 1 23 | return result 24 | 25 | 26 | MIN_INT16 = _min_int(16) 27 | MAX_INT16 = _max_int(16) 28 | 29 | MIN_INT24 = _min_int(24) 30 | MAX_INT24 = _max_int(24) 31 | 32 | MIN_INT128 = _min_int(128) 33 | MAX_INT128 = _max_int(128) 34 | 35 | MIN_INT256 = _min_int(256) 36 | MAX_INT256 = _max_int(256) 37 | 38 | MIN_UINT8 = _min_uint(8) 39 | MAX_UINT8 = _max_uint(8) 40 | 41 | MIN_UINT128 = _min_uint(128) 42 | MAX_UINT128 = _max_uint(128) 43 | 44 | MIN_UINT160 = _min_uint(160) 45 | MAX_UINT160 = _max_uint(160) 46 | 47 | MIN_UINT256 = _min_uint(256) 48 | MAX_UINT256 = _max_uint(256) 49 | 50 | ZERO_ADDRESS: ChecksumAddress = to_checksum_address("0x0000000000000000000000000000000000000000") 51 | 52 | 53 | # Contract addresses for the native blockchain token, keyed by chain ID 54 | WRAPPED_NATIVE_TOKENS: Dict[int, ChecksumAddress] = { 55 | # Ethereum (WETH) 56 | 1: to_checksum_address("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"), 57 | # Fantom (WFTM) 58 | 250: to_checksum_address("0x21be370D5312f44cB42ce377BC9b8a0cEF1A4C83"), 59 | # Arbitrum (AETH) 60 | 42161: to_checksum_address("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"), 61 | # Avalanche (WAVAX) 62 | 43114: to_checksum_address("0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7"), 63 | } 64 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v2_functions.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from typing import TYPE_CHECKING, Iterable, List, Sequence 3 | 4 | import eth_abi.packed 5 | from eth_typing import ChecksumAddress 6 | from eth_utils.address import to_checksum_address 7 | from hexbytes import HexBytes 8 | from web3 import Web3 9 | 10 | if TYPE_CHECKING: 11 | from .managers import UniswapV2LiquidityPoolManager 12 | from .v2_liquidity_pool import LiquidityPool 13 | 14 | 15 | def generate_v2_pool_address( 16 | token_addresses: Sequence[ChecksumAddress | str], 17 | factory_address: ChecksumAddress | str, 18 | init_hash: str, 19 | ) -> ChecksumAddress: 20 | """ 21 | Generate the deterministic pool address from the token addresses. 22 | 23 | Adapted from https://github.com/Uniswap/universal-router/blob/deployed-commit/contracts/modules/uniswap/v2/UniswapV2Library.sol 24 | """ 25 | 26 | token_addresses = sorted([address.lower() for address in token_addresses]) 27 | 28 | return to_checksum_address( 29 | Web3.keccak( 30 | HexBytes(0xFF) 31 | + HexBytes(factory_address) 32 | + Web3.keccak( 33 | eth_abi.packed.encode_packed( 34 | ["address", "address"], 35 | [*token_addresses], 36 | ) 37 | ) 38 | + HexBytes(init_hash) 39 | )[-20:] 40 | ) 41 | 42 | 43 | def get_v2_pools_from_token_path( 44 | tx_path: Iterable[ChecksumAddress | str], 45 | pool_manager: "UniswapV2LiquidityPoolManager", 46 | ) -> List["LiquidityPool"]: 47 | return [ 48 | pool_manager.get_pool( 49 | token_addresses=token_addresses, 50 | silent=True, 51 | ) 52 | for token_addresses in itertools.pairwise(tx_path) 53 | ] 54 | -------------------------------------------------------------------------------- /src/degenbot/registry/all_tokens.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from eth_typing import ChecksumAddress 4 | from eth_utils.address import to_checksum_address 5 | 6 | from ..baseclasses import BaseToken 7 | from ..logging import logger 8 | 9 | # Internal state dictionary that maintains a keyed dictionary of all token objects. The top level 10 | # dict is keyed by chain ID, and sub-dicts are keyed by the checksummed token address. 11 | _all_tokens: Dict[ 12 | int, 13 | Dict[ChecksumAddress, BaseToken], 14 | ] = {} 15 | 16 | 17 | class AllTokens: 18 | def __init__(self, chain_id: int) -> None: 19 | try: 20 | _all_tokens[chain_id] 21 | except KeyError: 22 | _all_tokens[chain_id] = {} 23 | finally: 24 | self.tokens = _all_tokens[chain_id] 25 | 26 | def __contains__(self, token: BaseToken | str) -> bool: 27 | if isinstance(token, BaseToken): 28 | _token_address = token.address 29 | else: 30 | _token_address = to_checksum_address(token) 31 | return _token_address in self.tokens 32 | 33 | def __delitem__(self, token: BaseToken | str) -> None: 34 | if isinstance(token, BaseToken): 35 | _token_address = token.address 36 | else: 37 | _token_address = to_checksum_address(token) 38 | del self.tokens[_token_address] 39 | 40 | def __getitem__(self, token_address: str) -> BaseToken: 41 | return self.tokens[to_checksum_address(token_address)] 42 | 43 | def __setitem__(self, token_address: str, token_helper: BaseToken) -> None: 44 | _token_address = to_checksum_address(token_address) 45 | if _token_address in self.tokens: # pragma: no cover 46 | logger.warning( 47 | f"Token with address {_token_address} already known. It has been overwritten." 48 | ) 49 | self.tokens[to_checksum_address(token_address)] = token_helper 50 | 51 | def __len__(self) -> int: # pragma: no cover 52 | return len(self.tokens) 53 | 54 | def get(self, token_address: str) -> BaseToken | None: 55 | return self.tokens.get(to_checksum_address(token_address)) 56 | -------------------------------------------------------------------------------- /src/degenbot/registry/all_pools.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from eth_typing import ChecksumAddress 4 | from eth_utils.address import to_checksum_address 5 | 6 | from ..baseclasses import BaseLiquidityPool 7 | from ..logging import logger 8 | 9 | # Internal state dictionary that maintains a keyed dictionary of all pool objects. The top level 10 | # dict is keyed by chain ID, and sub-dicts are keyed by the checksummed pool address. 11 | _all_pools: Dict[ 12 | int, 13 | Dict[ChecksumAddress, BaseLiquidityPool], 14 | ] = {} 15 | 16 | 17 | class AllPools: 18 | def __init__(self, chain_id: int) -> None: 19 | try: 20 | _all_pools[chain_id] 21 | except KeyError: 22 | _all_pools[chain_id] = {} 23 | finally: 24 | self.pools = _all_pools[chain_id] 25 | 26 | def __contains__(self, pool: BaseLiquidityPool | str) -> bool: 27 | if isinstance(pool, BaseLiquidityPool): 28 | _pool_address = pool.address 29 | else: 30 | _pool_address = to_checksum_address(pool) 31 | return _pool_address in self.pools 32 | 33 | def __delitem__(self, pool: BaseLiquidityPool | str) -> None: 34 | if isinstance(pool, BaseLiquidityPool): 35 | _pool_address = pool.address 36 | else: 37 | _pool_address = to_checksum_address(pool) 38 | del self.pools[_pool_address] 39 | 40 | def __getitem__(self, pool_address: str) -> BaseLiquidityPool: 41 | return self.pools[to_checksum_address(pool_address)] 42 | 43 | def __setitem__(self, pool_address: str, pool_helper: BaseLiquidityPool) -> None: 44 | _pool_address = to_checksum_address(pool_address) 45 | if _pool_address in self.pools: # pragma: no cover 46 | logger.warning( 47 | f"Pool with address {_pool_address} already known. It has been overwritten." 48 | ) 49 | self.pools[_pool_address] = pool_helper 50 | 51 | def __len__(self) -> int: # pragma: no cover 52 | return len(self.pools) 53 | 54 | def get(self, pool_address: str) -> BaseLiquidityPool | None: 55 | return self.pools.get(to_checksum_address(pool_address)) 56 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "degenbot" 3 | version = "0.2.3" 4 | authors = [ 5 | { name="BowTiedDevil", email="devil@bowtieddevil.com" }, 6 | ] 7 | description = "Python classes to aid rapid development of Uniswap V2 & V3, and Curve V1 arbitrage bots on EVM-compatible blockchains" 8 | readme = "README.md" 9 | requires-python = ">=3.10" 10 | dependencies = [ 11 | 'scipy >=1.12.0, <1.13', 12 | 'ujson >= 5.9.0, <6', 13 | 'web3 >=6.15.0, <7', 14 | ] 15 | license = {text = "MIT"} 16 | classifiers = [ 17 | "Programming Language :: Python :: 3", 18 | "License :: OSI Approved :: MIT License", 19 | "Development Status :: 3 - Alpha", 20 | "Intended Audience :: Developers", 21 | "Natural Language :: English", 22 | "Operating System :: POSIX", 23 | ] 24 | 25 | [project.optional-dependencies] 26 | tests = [ 27 | 'pytest', 28 | 'pytest-asyncio', 29 | 'pytest-cov', 30 | 'pytest-doc', 31 | 'pytest-xdist', 32 | 'python-dotenv', 33 | ] 34 | 35 | [project.urls] 36 | Homepage = "https://www.degencode.com" 37 | Repository = "https://github.com/BowTiedDevil/degenbot" 38 | Tracker = "https://github.com/BowTiedDevil/degenbot/issues" 39 | Twitter = "https://twitter.com/BowTiedDevil" 40 | 41 | [build-system] 42 | requires = ["setuptools"] 43 | build-backend = "setuptools.build_meta" 44 | 45 | [tool.mypy] 46 | files=[ 47 | "src/degenbot", 48 | "tests/", 49 | ] 50 | python_version = "3.10" 51 | 52 | [[tool.mypy.overrides]] 53 | module="degenbot.*" 54 | strict = true 55 | 56 | [[tool.mypy.overrides]] 57 | module="tests.*" 58 | disable_error_code = [ 59 | "no-untyped-def", 60 | ] 61 | 62 | [[tool.mypy.overrides]] 63 | module=[ 64 | "eth_abi.*", 65 | "scipy.*", 66 | "ujson.*", 67 | ] 68 | ignore_missing_imports = true 69 | 70 | [tool.ruff] 71 | line-length = 100 72 | indent-width = 4 73 | 74 | [tool.coverage.run] 75 | source = ["src/degenbot"] 76 | 77 | [tool.coverage.report] 78 | exclude_also = [ 79 | "if TYPE_CHECKING:", # exclude type checking imports and checks 80 | "except Exception", # exclude catch-alls 81 | "return NotImplemented", # required for __eq__, __lt__, __gt__ 82 | "def __hash__", 83 | "def __repr__", 84 | "def __str__", 85 | "logger.debug", 86 | ] 87 | 88 | [tool.pytest.ini_options] 89 | asyncio_mode = "auto" 90 | addopts = "--cov --cov-branch --cov-report=html" 91 | python_files = "test_*.py" 92 | testpaths = "tests" 93 | -------------------------------------------------------------------------------- /src/degenbot/manager/token_manager.py: -------------------------------------------------------------------------------- 1 | from threading import Lock 2 | from typing import Any, Dict 3 | 4 | from eth_typing import ChecksumAddress 5 | from eth_utils.address import to_checksum_address 6 | 7 | from .. import config 8 | from ..baseclasses import BaseManager 9 | from ..erc20_token import EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE, Erc20Token 10 | from ..exceptions import ManagerError 11 | 12 | 13 | class Erc20TokenHelperManager(BaseManager): 14 | """ 15 | A class that generates and tracks Erc20Token helpers 16 | 17 | The state dictionary is held using the "Borg" singleton pattern, which 18 | ensures that all instances of the class have access to the same state data 19 | """ 20 | 21 | _state: Dict[int, Dict[str, Any]] = {} 22 | 23 | def __init__(self, chain_id: int | None = None) -> None: 24 | chain_id = chain_id if chain_id is not None else config.get_web3().eth.chain_id 25 | 26 | # the internal state data for this object is held in the 27 | # class-level _state dictionary, keyed by the chain ID 28 | if self._state.get(chain_id): 29 | self.__dict__ = self._state[chain_id] 30 | else: 31 | self._state[chain_id] = {} 32 | self.__dict__ = self._state[chain_id] 33 | 34 | # initialize internal attributes 35 | self._erc20tokens: Dict[ChecksumAddress, Erc20Token] = {} 36 | self._lock = Lock() 37 | 38 | def get_erc20token( 39 | self, 40 | address: str, 41 | # accept any number of keyword arguments, which are 42 | # passed directly to Erc20Token without validation 43 | **kwargs: Any, 44 | ) -> Erc20Token: 45 | """ 46 | Get the token object from its address 47 | """ 48 | 49 | address = to_checksum_address(address) 50 | 51 | if token_helper := self._erc20tokens.get(address): 52 | return token_helper 53 | 54 | try: 55 | if address == EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE.address: 56 | token_helper = EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE() 57 | else: 58 | token_helper = Erc20Token(address=address, **kwargs) 59 | except Exception: 60 | raise ManagerError(f"Could not create Erc20Token helper: {address=}") 61 | 62 | with self._lock: 63 | self._erc20tokens[address] = token_helper 64 | 65 | return token_helper 66 | -------------------------------------------------------------------------------- /tests/test_registry.py: -------------------------------------------------------------------------------- 1 | from degenbot.config import set_web3 2 | from degenbot.erc20_token import Erc20Token 3 | from degenbot.fork.anvil_fork import AnvilFork 4 | from degenbot.registry.all_pools import AllPools 5 | from degenbot.registry.all_tokens import AllTokens 6 | from degenbot.uniswap.v2_liquidity_pool import LiquidityPool 7 | from eth_utils.address import to_checksum_address 8 | 9 | UNISWAP_V2_WBTC_WETH_POOL = to_checksum_address("0xBb2b8038a1640196FbE3e38816F3e67Cba72D940") 10 | 11 | 12 | def test_adding_pool(fork_mainnet: AnvilFork): 13 | set_web3(fork_mainnet.w3) 14 | all_pools = AllPools(fork_mainnet.w3.eth.chain_id) 15 | lp = LiquidityPool(UNISWAP_V2_WBTC_WETH_POOL) 16 | assert lp in all_pools 17 | assert lp.address in all_pools 18 | assert all_pools[lp.address] is lp 19 | 20 | 21 | def test_deleting_pool(fork_mainnet: AnvilFork): 22 | set_web3(fork_mainnet.w3) 23 | all_pools = AllPools(fork_mainnet.w3.eth.chain_id) 24 | lp = LiquidityPool(UNISWAP_V2_WBTC_WETH_POOL) 25 | assert lp in all_pools 26 | del all_pools[lp] 27 | assert lp not in all_pools 28 | 29 | 30 | def test_deleting_pool_by_address(fork_mainnet: AnvilFork): 31 | set_web3(fork_mainnet.w3) 32 | all_pools = AllPools(fork_mainnet.w3.eth.chain_id) 33 | lp = LiquidityPool(UNISWAP_V2_WBTC_WETH_POOL) 34 | assert lp in all_pools 35 | del all_pools[lp.address] 36 | assert lp not in all_pools 37 | 38 | 39 | def test_adding_token(fork_mainnet: AnvilFork): 40 | set_web3(fork_mainnet.w3) 41 | all_tokens = AllTokens(fork_mainnet.w3.eth.chain_id) 42 | weth = Erc20Token("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") 43 | assert weth in all_tokens 44 | assert weth.address in all_tokens 45 | assert all_tokens[weth.address] is weth 46 | 47 | 48 | def test_deleting_token(fork_mainnet: AnvilFork): 49 | set_web3(fork_mainnet.w3) 50 | all_tokens = AllTokens(fork_mainnet.w3.eth.chain_id) 51 | weth = Erc20Token("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") 52 | assert weth in all_tokens 53 | del all_tokens[weth] 54 | assert weth not in all_tokens 55 | 56 | 57 | def test_deleting_token_by_address(fork_mainnet: AnvilFork): 58 | set_web3(fork_mainnet.w3) 59 | all_tokens = AllTokens(fork_mainnet.w3.eth.chain_id) 60 | weth = Erc20Token("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") 61 | assert weth in all_tokens 62 | del all_tokens[weth.address] 63 | assert weth not in all_tokens 64 | -------------------------------------------------------------------------------- /tests/uniswap/v3/test_uniswap_v3_functions.py: -------------------------------------------------------------------------------- 1 | from fractions import Fraction 2 | 3 | import pytest 4 | from degenbot.uniswap.v3_functions import ( 5 | decode_v3_path, 6 | exchange_rate_from_sqrt_price_x96, 7 | generate_v3_pool_address, 8 | ) 9 | from hexbytes import HexBytes 10 | 11 | WBTC_ADDRESS = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" 12 | WETH_ADDRESS = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" 13 | WBTC_WETH_LP_ADDRESS = "0xCBCdF9626bC03E24f779434178A73a0B4bad62eD" 14 | WBTC_WETH_LP_FEE = 3000 15 | 16 | 17 | def test_v3_address_generator() -> None: 18 | # Should generate address for Uniswap V3 WETH/WBTC pool 19 | # factory ref: https://etherscan.io/address/0x1F98431c8aD98523631AE4a59f267346ea31F984 20 | # WETH ref: https://etherscan.io/address/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 21 | # WBTC ref: https://etherscan.io/address/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599 22 | # pool ref: https://etherscan.io/address/0xcbcdf9626bc03e24f779434178a73a0b4bad62ed 23 | wbtc_weth_address = generate_v3_pool_address( 24 | token_addresses=[WBTC_ADDRESS, WETH_ADDRESS], 25 | fee=WBTC_WETH_LP_FEE, 26 | factory_address="0x1F98431c8aD98523631AE4a59f267346ea31F984", 27 | init_hash="0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54", 28 | ) 29 | assert wbtc_weth_address == WBTC_WETH_LP_ADDRESS 30 | 31 | # address generator returns a checksum address, so check against the lowered string 32 | with pytest.raises(AssertionError): 33 | assert wbtc_weth_address == WBTC_WETH_LP_ADDRESS.lower() 34 | 35 | 36 | def test_v3_decode_path() -> None: 37 | path = ( 38 | HexBytes(WBTC_ADDRESS) 39 | + HexBytes((WBTC_WETH_LP_FEE).to_bytes(length=3, byteorder="big")) # pad to 3 bytes 40 | + HexBytes(WETH_ADDRESS) 41 | ) 42 | assert decode_v3_path(path) == [WBTC_ADDRESS, WBTC_WETH_LP_FEE, WETH_ADDRESS] 43 | 44 | for fee in (100, 500, 3000, 10000): 45 | path = ( 46 | HexBytes(WBTC_ADDRESS) 47 | + HexBytes((fee).to_bytes(length=3, byteorder="big")) # pad to 3 bytes 48 | + HexBytes(WETH_ADDRESS) 49 | ) 50 | assert decode_v3_path(path) == [WBTC_ADDRESS, fee, WETH_ADDRESS] 51 | 52 | 53 | def test_v3_exchange_rates_from_sqrt_price_x96() -> None: 54 | PRICE = 2018382873588440326581633304624437 55 | assert ( 56 | exchange_rate_from_sqrt_price_x96(PRICE) 57 | == Fraction(2018382873588440326581633304624437, 2**96) ** 2 58 | ) 59 | -------------------------------------------------------------------------------- /tests/manager/test_erc20tokenmanager.py: -------------------------------------------------------------------------------- 1 | import web3 2 | from degenbot.config import set_web3 3 | from degenbot.manager.token_manager import Erc20TokenHelperManager 4 | from degenbot.registry.all_tokens import AllTokens 5 | from eth_utils.address import to_checksum_address 6 | 7 | WETH_ADDRESS = to_checksum_address("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") 8 | WBTC_ADDRESS = to_checksum_address("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599") 9 | ETHER_PLACEHOLDER_ADDRESS = to_checksum_address("0xEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE") 10 | 11 | 12 | def test_get_erc20tokens(ethereum_full_node_web3: web3.Web3): 13 | set_web3(ethereum_full_node_web3) 14 | token_manager = Erc20TokenHelperManager(chain_id=ethereum_full_node_web3.eth.chain_id) 15 | token_registry = AllTokens(chain_id=ethereum_full_node_web3.eth.chain_id) 16 | 17 | weth = token_manager.get_erc20token(address=WETH_ADDRESS) 18 | assert weth.symbol == "WETH" 19 | assert weth.address == WETH_ADDRESS 20 | assert token_manager.get_erc20token(WETH_ADDRESS) is weth 21 | assert token_manager.get_erc20token(WETH_ADDRESS.lower()) is weth 22 | assert token_manager.get_erc20token(WETH_ADDRESS.upper()) is weth 23 | assert token_registry.get(WETH_ADDRESS) is weth 24 | 25 | wbtc = token_manager.get_erc20token(address=WBTC_ADDRESS) 26 | assert wbtc.symbol == "WBTC" 27 | assert wbtc.address == WBTC_ADDRESS 28 | assert token_manager.get_erc20token(WBTC_ADDRESS) is wbtc 29 | assert token_manager.get_erc20token(WBTC_ADDRESS.lower()) is wbtc 30 | assert token_manager.get_erc20token(WBTC_ADDRESS.upper()) is wbtc 31 | assert token_registry.get(WBTC_ADDRESS) is wbtc 32 | 33 | 34 | def test_get_ether_placeholder(ethereum_full_node_web3: web3.Web3): 35 | set_web3(ethereum_full_node_web3) 36 | token_manager = Erc20TokenHelperManager(chain_id=ethereum_full_node_web3.eth.chain_id) 37 | token_registry = AllTokens(chain_id=ethereum_full_node_web3.eth.chain_id) 38 | 39 | ether_placeholder = token_manager.get_erc20token(address=ETHER_PLACEHOLDER_ADDRESS) 40 | assert ether_placeholder.symbol == "ETH" 41 | assert ether_placeholder.address == ETHER_PLACEHOLDER_ADDRESS 42 | assert token_manager.get_erc20token(ETHER_PLACEHOLDER_ADDRESS) is ether_placeholder 43 | assert token_manager.get_erc20token(ETHER_PLACEHOLDER_ADDRESS.lower()) is ether_placeholder 44 | assert token_manager.get_erc20token(ETHER_PLACEHOLDER_ADDRESS.upper()) is ether_placeholder 45 | assert token_registry.get(ETHER_PLACEHOLDER_ADDRESS) is ether_placeholder 46 | assert token_registry.get(ETHER_PLACEHOLDER_ADDRESS.lower()) is ether_placeholder 47 | assert token_registry.get(ETHER_PLACEHOLDER_ADDRESS.upper()) is ether_placeholder 48 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v3_dataclasses.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from typing import Any, Dict, Tuple 3 | 4 | from eth_typing import ChecksumAddress 5 | 6 | from ..baseclasses import BasePoolState, Message, UniswapSimulationResult 7 | 8 | 9 | @dataclasses.dataclass(slots=True) 10 | class UniswapV3BitmapAtWord: 11 | bitmap: int = 0 12 | block: int | None = dataclasses.field(compare=False, default=None) 13 | 14 | def to_dict(self) -> Dict[str, Any]: 15 | return dataclasses.asdict(self) 16 | 17 | 18 | @dataclasses.dataclass(slots=True) 19 | class UniswapV3LiquidityAtTick: 20 | liquidityNet: int = 0 21 | liquidityGross: int = 0 22 | block: int | None = dataclasses.field(compare=False, default=None) 23 | 24 | def to_dict(self) -> Dict[str, Any]: 25 | return dataclasses.asdict(self) 26 | 27 | 28 | @dataclasses.dataclass(slots=True) 29 | class UniswapV3LiquidityEvent: 30 | block_number: int 31 | liquidity: int 32 | tick_lower: int 33 | tick_upper: int 34 | tx_index: int 35 | 36 | 37 | @dataclasses.dataclass(slots=True, eq=False) 38 | class UniswapV3PoolExternalUpdate: 39 | block_number: int = dataclasses.field(compare=False) 40 | liquidity: int | None = None 41 | sqrt_price_x96: int | None = None 42 | tick: int | None = None 43 | liquidity_change: ( 44 | Tuple[ 45 | int, # Liquidity 46 | int, # TickLower 47 | int, # TickUpper 48 | ] 49 | | None 50 | ) = None 51 | tx: str | None = dataclasses.field(compare=False, default=None) 52 | 53 | 54 | @dataclasses.dataclass(slots=True, frozen=True) 55 | class UniswapV3PoolState(BasePoolState): 56 | pool: ChecksumAddress 57 | liquidity: int 58 | sqrt_price_x96: int 59 | tick: int 60 | tick_bitmap: Dict[int, UniswapV3BitmapAtWord] | None = dataclasses.field(default=None) 61 | tick_data: Dict[int, UniswapV3LiquidityAtTick] | None = dataclasses.field(default=None) 62 | 63 | def copy(self) -> "UniswapV3PoolState": 64 | return UniswapV3PoolState( 65 | pool=self.pool, 66 | liquidity=self.liquidity, 67 | sqrt_price_x96=self.sqrt_price_x96, 68 | tick=self.tick, 69 | tick_bitmap=self.tick_bitmap.copy() if self.tick_bitmap is not None else None, 70 | tick_data=self.tick_data.copy() if self.tick_data is not None else None, 71 | ) 72 | 73 | 74 | @dataclasses.dataclass(slots=True, frozen=True) 75 | class UniswapV3PoolSimulationResult(UniswapSimulationResult): 76 | initial_state: UniswapV3PoolState = dataclasses.field(compare=False) 77 | final_state: UniswapV3PoolState = dataclasses.field(compare=False) 78 | 79 | 80 | @dataclasses.dataclass(slots=True, frozen=True) 81 | class UniswapV3PoolStateUpdated(Message): 82 | state: UniswapV3PoolState 83 | -------------------------------------------------------------------------------- /tests/uniswap/v3/libraries/test_tick.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal, getcontext 2 | from math import ceil, floor 3 | 4 | from degenbot.constants import MAX_UINT128 5 | from degenbot.uniswap.v3_libraries import Tick 6 | 7 | # Tests adapted from Typescript tests on Uniswap V3 Github repo 8 | # ref: https://github.com/Uniswap/v3-core/blob/main/test/Tick.spec.ts 9 | 10 | FEE_AMOUNT = { 11 | "LOW": 500, 12 | "MEDIUM": 3000, 13 | "HIGH": 10000, 14 | } 15 | 16 | TICK_SPACINGS = { 17 | FEE_AMOUNT["LOW"]: 10, 18 | FEE_AMOUNT["MEDIUM"]: 60, 19 | FEE_AMOUNT["HIGH"]: 200, 20 | } 21 | 22 | # Change the rounding method to match the BigNumber unit test at https://github.com/Uniswap/v3-core/blob/main/test/shared/utilities.ts 23 | # which specifies .integerValue(3), the 'ROUND_FLOOR' rounding method per https://mikemcl.github.io/bignumber.js/#bignumber 24 | getcontext().prec = 256 25 | getcontext().rounding = "ROUND_FLOOR" 26 | 27 | 28 | def getMaxLiquidityPerTick(tick_spacing: int) -> int: 29 | def getMinTick(tick_spacing: int) -> int: 30 | return ceil(Decimal(-887272) / tick_spacing) * tick_spacing 31 | 32 | def getMaxTick(tick_spacing: int) -> int: 33 | return floor(Decimal(887272) / tick_spacing) * tick_spacing 34 | 35 | return round( 36 | (2**128 - 1) // (1 + (getMaxTick(tick_spacing) - getMinTick(tick_spacing)) // tick_spacing) 37 | ) 38 | 39 | 40 | def test_tickSpacingToMaxLiquidityPerTick(): 41 | maxLiquidityPerTick = Tick.tickSpacingToMaxLiquidityPerTick(TICK_SPACINGS[FEE_AMOUNT["HIGH"]]) 42 | assert maxLiquidityPerTick == getMaxLiquidityPerTick(TICK_SPACINGS[FEE_AMOUNT["HIGH"]]) 43 | assert maxLiquidityPerTick == 38350317471085141830651933667504588 44 | 45 | # returns the correct value for low fee 46 | maxLiquidityPerTick = Tick.tickSpacingToMaxLiquidityPerTick(TICK_SPACINGS[FEE_AMOUNT["LOW"]]) 47 | assert maxLiquidityPerTick == getMaxLiquidityPerTick(TICK_SPACINGS[FEE_AMOUNT["LOW"]]) 48 | assert maxLiquidityPerTick == 1917569901783203986719870431555990 # 110.8 bits 49 | 50 | maxLiquidityPerTick = Tick.tickSpacingToMaxLiquidityPerTick(TICK_SPACINGS[FEE_AMOUNT["MEDIUM"]]) 51 | assert (maxLiquidityPerTick) == 11505743598341114571880798222544994 # 113.1 bits 52 | assert (maxLiquidityPerTick) == (getMaxLiquidityPerTick(TICK_SPACINGS[FEE_AMOUNT["MEDIUM"]])) 53 | 54 | # returns the correct value for entire range 55 | maxLiquidityPerTick = Tick.tickSpacingToMaxLiquidityPerTick(887272) 56 | assert (maxLiquidityPerTick) == round(Decimal(MAX_UINT128) / Decimal(3)) # 126 bits 57 | assert maxLiquidityPerTick == getMaxLiquidityPerTick(887272) 58 | 59 | # returns the correct value for 2302 60 | maxLiquidityPerTick = Tick.tickSpacingToMaxLiquidityPerTick(2302) 61 | assert maxLiquidityPerTick == getMaxLiquidityPerTick(2302) 62 | assert (maxLiquidityPerTick) == 441351967472034323558203122479595605 # 118 bits 63 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yaml: -------------------------------------------------------------------------------- 1 | name: Publish distribution to PyPI 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | name: Build distribution 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: "3.x" 16 | - name: Install pypa/build 17 | run: >- 18 | python3 -m 19 | pip install 20 | build 21 | --user 22 | - name: Build a binary wheel and a source tarball 23 | run: python3 -m build 24 | - name: Store the distribution packages 25 | uses: actions/upload-artifact@v3 26 | with: 27 | name: python-package-distributions 28 | path: dist/ 29 | 30 | publish-to-pypi: 31 | name: >- 32 | Publish distribution to PyPI 33 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 34 | needs: 35 | - build 36 | runs-on: ubuntu-latest 37 | environment: 38 | name: pypi 39 | url: https://pypi.org/p/degenbot # Replace with your PyPI project name 40 | permissions: 41 | id-token: write # IMPORTANT: mandatory for trusted publishing 42 | 43 | steps: 44 | - name: Download all the dists 45 | uses: actions/download-artifact@v3 46 | with: 47 | name: python-package-distributions 48 | path: dist/ 49 | - name: Publish distribution to PyPI 50 | uses: pypa/gh-action-pypi-publish@release/v1 51 | 52 | 53 | github-release: 54 | name: >- 55 | Sign the distribution with Sigstore and upload to GitHub Release 56 | needs: 57 | - publish-to-pypi 58 | runs-on: ubuntu-latest 59 | 60 | permissions: 61 | contents: write # IMPORTANT: mandatory for making GitHub Releases 62 | id-token: write # IMPORTANT: mandatory for sigstore 63 | 64 | steps: 65 | - name: Download all the dists 66 | uses: actions/download-artifact@v3 67 | with: 68 | name: python-package-distributions 69 | path: dist/ 70 | - name: Sign the dists with Sigstore 71 | uses: sigstore/gh-action-sigstore-python@v2.1.1 72 | with: 73 | inputs: >- 74 | ./dist/*.tar.gz 75 | ./dist/*.whl 76 | - name: Create GitHub Release 77 | env: 78 | GITHUB_TOKEN: ${{ github.token }} 79 | run: >- 80 | gh release create 81 | '${{ github.ref_name }}' 82 | --repo '${{ github.repository }}' 83 | --notes "" 84 | - name: Upload artifact signatures to GitHub Release 85 | env: 86 | GITHUB_TOKEN: ${{ github.token }} 87 | # Upload to GitHub Release using the `gh` CLI. 88 | # `dist/` contains the built packages, and the 89 | # sigstore-produced signatures and certificates. 90 | run: >- 91 | gh release upload 92 | '${{ github.ref_name }}' dist/** 93 | --repo '${{ github.repository }}' 94 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v3_functions.py: -------------------------------------------------------------------------------- 1 | from fractions import Fraction 2 | from itertools import cycle 3 | from typing import Callable, Iterable, Iterator, List, Tuple 4 | 5 | import eth_abi.abi 6 | from eth_typing import ChecksumAddress 7 | from eth_utils.address import to_checksum_address 8 | from hexbytes import HexBytes 9 | from web3 import Web3 10 | 11 | 12 | def decode_v3_path(path: bytes) -> List[ChecksumAddress | int]: 13 | """ 14 | Decode the `path` bytes used by the Uniswap V3 Router/Router2 contracts. `path` is a 15 | close-packed encoding of 20 byte pool addresses, interleaved with 3 byte fees. 16 | """ 17 | ADDRESS_BYTES = 20 18 | FEE_BYTES = 3 19 | 20 | def _extract_address(chunk: bytes) -> ChecksumAddress: 21 | return to_checksum_address(chunk) 22 | 23 | def _extract_fee(chunk: bytes) -> int: 24 | return int.from_bytes(chunk, byteorder="big") 25 | 26 | if any( 27 | [ 28 | len(path) < ADDRESS_BYTES + FEE_BYTES + ADDRESS_BYTES, 29 | len(path) % (ADDRESS_BYTES + FEE_BYTES) != ADDRESS_BYTES, 30 | ] 31 | ): # pragma: no cover 32 | raise ValueError("Invalid path.") 33 | 34 | chunk_length_and_decoder_function: Iterator[ 35 | Tuple[ 36 | int, 37 | Callable[ 38 | [bytes], 39 | ChecksumAddress | int, 40 | ], 41 | ] 42 | ] = cycle( 43 | [ 44 | (ADDRESS_BYTES, _extract_address), 45 | (FEE_BYTES, _extract_fee), 46 | ] 47 | ) 48 | 49 | path_offset = 0 50 | decoded_path: List[ChecksumAddress | int] = [] 51 | while path_offset != len(path): 52 | byte_length, extraction_func = next(chunk_length_and_decoder_function) 53 | chunk = HexBytes(path[path_offset : path_offset + byte_length]) 54 | decoded_path.append(extraction_func(chunk)) 55 | path_offset += byte_length 56 | 57 | return decoded_path 58 | 59 | 60 | def exchange_rate_from_sqrt_price_x96(sqrt_price_x96: int) -> Fraction: 61 | # ref: https://blog.uniswap.org/uniswap-v3-math-primer 62 | return Fraction(sqrt_price_x96**2, 2**192) 63 | 64 | 65 | def generate_v3_pool_address( 66 | token_addresses: Iterable[str], 67 | fee: int, 68 | factory_address: str, 69 | init_hash: str, 70 | ) -> ChecksumAddress: 71 | """ 72 | Generate the deterministic pool address from the token addresses and fee. 73 | 74 | Adapted from https://github.com/Uniswap/v3-periphery/blob/main/contracts/libraries/PoolAddress.sol 75 | """ 76 | 77 | token_addresses = sorted([address.lower() for address in token_addresses]) 78 | 79 | return to_checksum_address( 80 | Web3.keccak( 81 | HexBytes(0xFF) 82 | + HexBytes(factory_address) 83 | + Web3.keccak( 84 | eth_abi.abi.encode( 85 | types=("address", "address", "uint24"), 86 | args=(*token_addresses, fee), 87 | ) 88 | ) 89 | + HexBytes(init_hash) 90 | )[-20:] # last 20 bytes of the keccak hash becomes the pool address 91 | ) 92 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | 4 | import degenbot 5 | import degenbot.config 6 | import degenbot.logging 7 | import degenbot.manager 8 | import degenbot.registry 9 | import degenbot.uniswap.managers 10 | import dotenv 11 | import pytest 12 | import web3 13 | from degenbot.fork.anvil_fork import AnvilFork 14 | from degenbot.uniswap.v3_liquidity_pool import V3LiquidityPool 15 | 16 | env_file = dotenv.find_dotenv("tests.env") 17 | env_values = dotenv.dotenv_values(env_file) 18 | 19 | 20 | @pytest.fixture(scope="session") 21 | def load_env() -> dict[str, Any]: 22 | env_file = dotenv.find_dotenv("tests.env") 23 | return dotenv.dotenv_values(env_file) 24 | 25 | 26 | ARBITRUM_ARCHIVE_NODE_HTTP_URI = f"https://rpc.ankr.com/arbitrum/{env_values['ANKR_API_KEY']}" 27 | # ARBITRUM_FULL_NODE_HTTP_URI = "http://localhost:8547" 28 | ARBITRUM_FULL_NODE_HTTP_URI = f"https://rpc.ankr.com/arbitrum/{env_values['ANKR_API_KEY']}" 29 | 30 | ETHEREUM_ARCHIVE_NODE_HTTP_URI = "http://localhost:8543" 31 | # ETHEREUM_ARCHIVE_NODE_HTTP_URI = f"https://rpc.ankr.com/eth/{env_values['ANKR_API_KEY']}" 32 | ETHEREUM_FULL_NODE_HTTP_URI = "http://localhost:8545" 33 | 34 | 35 | # Set up a web3 connection to an Arbitrum full node 36 | @pytest.fixture(scope="session") 37 | def arbitrum_full_node_web3() -> web3.Web3: 38 | w3 = web3.Web3(web3.HTTPProvider(ARBITRUM_FULL_NODE_HTTP_URI)) 39 | return w3 40 | 41 | 42 | # Set up a web3 connection to an Ethereum archive node 43 | @pytest.fixture(scope="session") 44 | def ethereum_archive_node_web3() -> web3.Web3: 45 | w3 = web3.Web3(web3.HTTPProvider(ETHEREUM_ARCHIVE_NODE_HTTP_URI)) 46 | return w3 47 | 48 | 49 | # Set up a web3 connection to an Ethereum full node 50 | @pytest.fixture(scope="session") 51 | def ethereum_full_node_web3() -> web3.Web3: 52 | w3 = web3.Web3(web3.HTTPProvider(ETHEREUM_FULL_NODE_HTTP_URI)) 53 | return w3 54 | 55 | 56 | # After each test, clear shared state dictionaries 57 | @pytest.fixture(autouse=True) 58 | def initialize_and_reset_after_each_test(ethereum_full_node_web3): 59 | # degenbot.config.set_web3(ethereum_full_node_web3) 60 | yield 61 | degenbot.registry.all_pools._all_pools.clear() 62 | degenbot.registry.all_tokens._all_tokens.clear() 63 | degenbot.manager.token_manager.Erc20TokenHelperManager._state.clear() 64 | degenbot.uniswap.managers.UniswapLiquidityPoolManager._state.clear() 65 | V3LiquidityPool._lens_contracts.clear() 66 | 67 | 68 | @pytest.fixture(autouse=True) 69 | def set_degenbot_logging(): 70 | degenbot.logging.logger.setLevel(logging.DEBUG) 71 | 72 | 73 | @pytest.fixture() 74 | def fork_mainnet_archive() -> AnvilFork: 75 | fork = AnvilFork(fork_url=ETHEREUM_ARCHIVE_NODE_HTTP_URI) 76 | return fork 77 | 78 | 79 | @pytest.fixture() 80 | def fork_mainnet() -> AnvilFork: 81 | fork = AnvilFork(fork_url=ETHEREUM_FULL_NODE_HTTP_URI) 82 | return fork 83 | 84 | 85 | @pytest.fixture() 86 | def fork_arbitrum() -> AnvilFork: 87 | fork = AnvilFork(fork_url=ARBITRUM_FULL_NODE_HTTP_URI) 88 | return fork 89 | 90 | 91 | @pytest.fixture() 92 | def fork_arbitrum_archive() -> AnvilFork: 93 | fork = AnvilFork(fork_url=ARBITRUM_ARCHIVE_NODE_HTTP_URI) 94 | return fork 95 | -------------------------------------------------------------------------------- /src/degenbot/functions.py: -------------------------------------------------------------------------------- 1 | import eth_account.messages 2 | import web3 3 | from eth_account.datastructures import SignedMessage 4 | from web3.types import BlockIdentifier 5 | 6 | from . import config 7 | from .constants import MAX_UINT256 8 | 9 | 10 | def eip_191_hash(message: str, private_key: str) -> str: 11 | """ 12 | Get the signature hash (a hex-formatted string) for a given message and signing key. 13 | """ 14 | result: SignedMessage = eth_account.Account.sign_message( 15 | signable_message=eth_account.messages.encode_defunct( 16 | text=web3.Web3.keccak(text=message).hex() 17 | ), 18 | private_key=private_key, 19 | ) 20 | return result.signature.hex() 21 | 22 | 23 | def get_number_for_block_identifier(identifier: BlockIdentifier | None) -> int: 24 | match identifier: 25 | case None: 26 | return config.get_web3().eth.get_block_number() 27 | case int() if 1 <= identifier <= MAX_UINT256: 28 | return identifier 29 | case bytes(): 30 | return int.from_bytes(identifier, byteorder="big") 31 | case str() if isinstance(identifier, str) and identifier[:2] == "0x" and len( 32 | identifier 33 | ) == 66: 34 | return int(identifier, 16) 35 | case "latest" | "earliest" | "pending" | "safe" | "finalized": 36 | # These tags vary with each new block, so translate to a fixed block number 37 | return config.get_web3().eth.get_block(identifier)["number"] 38 | case _: 39 | raise ValueError(f"Invalid block identifier {identifier!r}") 40 | 41 | 42 | def next_base_fee( 43 | parent_base_fee: int, 44 | parent_gas_used: int, 45 | parent_gas_limit: int, 46 | min_base_fee: int | None = None, 47 | base_fee_max_change_denominator: int = 8, # limits the maximum base fee increase per block to 1/8 (12.5%) 48 | elasticity_multiplier: int = 2, 49 | ) -> int: 50 | """ 51 | Calculate next base fee for an EIP-1559 compatible blockchain. The 52 | formula is taken from the example code in the EIP-1559 proposal (ref: 53 | https://eips.ethereum.org/EIPS/eip-1559). 54 | 55 | The default values for `base_fee_max_change_denominator` and 56 | `elasticity_multiplier` are taken from EIP-1559. 57 | 58 | Enforces `min_base_fee` if provided. 59 | """ 60 | 61 | last_gas_target = parent_gas_limit // elasticity_multiplier 62 | 63 | if parent_gas_used == last_gas_target: 64 | next_base_fee = parent_base_fee 65 | elif parent_gas_used > last_gas_target: 66 | gas_used_delta = parent_gas_used - last_gas_target 67 | base_fee_delta = max( 68 | parent_base_fee * gas_used_delta // last_gas_target // base_fee_max_change_denominator, 69 | 1, 70 | ) 71 | next_base_fee = parent_base_fee + base_fee_delta 72 | else: 73 | gas_used_delta = last_gas_target - parent_gas_used 74 | base_fee_delta = ( 75 | parent_base_fee * gas_used_delta // last_gas_target // base_fee_max_change_denominator 76 | ) 77 | next_base_fee = parent_base_fee - base_fee_delta 78 | 79 | return max(min_base_fee, next_base_fee) if min_base_fee else next_base_fee 80 | -------------------------------------------------------------------------------- /src/degenbot/dex/uniswap.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | from eth_typing import ChecksumAddress 4 | from eth_utils.address import to_checksum_address 5 | 6 | 7 | FACTORY_ADDRESSES: Dict[ 8 | int, # Chain ID 9 | Dict[ 10 | ChecksumAddress, # Factory address 11 | Dict[str, Any], 12 | ], 13 | ] = { 14 | 1: { 15 | # Uniswap (V2) 16 | to_checksum_address("0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f"): { 17 | "init_hash": "0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f" 18 | }, 19 | # Uniswap (V3) 20 | to_checksum_address("0x1F98431c8aD98523631AE4a59f267346ea31F984"): { 21 | "init_hash": "0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54" 22 | }, 23 | # Sushiswap (V2) 24 | to_checksum_address("0xC0AEe478e3658e2610c5F7A4A2E1777cE9e4f2Ac"): { 25 | "init_hash": "0xe18a34eb0e04b04f7a0ac29a6e80748dca96319b42c54d679cb821dca90c6303" 26 | }, 27 | # Sushiswap (V3) 28 | to_checksum_address("0xbACEB8eC6b9355Dfc0269C18bac9d6E2Bdc29C4F"): { 29 | "init_hash": "0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54" 30 | }, 31 | }, 32 | 42161: { 33 | # Uniswap (V3) 34 | to_checksum_address("0x1F98431c8aD98523631AE4a59f267346ea31F984"): { 35 | "init_hash": "0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54" 36 | }, 37 | # Sushiswap (V2) 38 | to_checksum_address("0xc35DADB65012eC5796536bD9864eD8773aBc74C4"): { 39 | "init_hash": "0xe18a34eb0e04b04f7a0ac29a6e80748dca96319b42c54d679cb821dca90c6303" 40 | }, 41 | # Sushiswap (V3) 42 | to_checksum_address("0x1af415a1EbA07a4986a52B6f2e7dE7003D82231e"): { 43 | "init_hash": "0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54" 44 | }, 45 | }, 46 | } 47 | 48 | 49 | TICKLENS_ADDRESSES: Dict[ 50 | int, # Chain ID 51 | Dict[ 52 | ChecksumAddress, # Factory address 53 | ChecksumAddress, # TickLens address 54 | ], 55 | ] = { 56 | # Ethereum Mainnet 57 | 1: { 58 | # Uniswap V3 59 | # ref: https://docs.uniswap.org/contracts/v3/reference/deployments 60 | to_checksum_address("0x1F98431c8aD98523631AE4a59f267346ea31F984"): to_checksum_address( 61 | "0xbfd8137f7d1516D3ea5cA83523914859ec47F573" 62 | ), 63 | # Sushiswap V3 64 | # ref: https://docs.sushi.com/docs/Products/V3%20AMM/Periphery/Deployment%20Addresses 65 | to_checksum_address("0xbACEB8eC6b9355Dfc0269C18bac9d6E2Bdc29C4F"): to_checksum_address( 66 | "0xFB70AD5a200d784E7901230E6875d91d5Fa6B68c" 67 | ), 68 | }, 69 | # Arbitrum 70 | 42161: { 71 | # Uniswap V3 72 | # ref: https://docs.uniswap.org/contracts/v3/reference/deployments 73 | to_checksum_address("0x1F98431c8aD98523631AE4a59f267346ea31F984"): to_checksum_address( 74 | "0xbfd8137f7d1516D3ea5cA83523914859ec47F573" 75 | ), 76 | # Sushiswap V3 77 | # ref: https://docs.sushi.com/docs/Products/V3%20AMM/Periphery/Deployment%20Addresses 78 | to_checksum_address("0x1af415a1EbA07a4986a52B6f2e7dE7003D82231e"): to_checksum_address( 79 | "0x8516944E89f296eb6473d79aED1Ba12088016c9e" 80 | ), 81 | }, 82 | } 83 | -------------------------------------------------------------------------------- /tests/test_functions.py: -------------------------------------------------------------------------------- 1 | from degenbot.functions import next_base_fee, get_number_for_block_identifier 2 | from degenbot.constants import MAX_UINT256 3 | import pytest 4 | import degenbot.config 5 | from eth_typing import ( 6 | BlockNumber, 7 | Hash32, 8 | HexStr, 9 | ) 10 | from hexbytes import HexBytes 11 | 12 | 13 | def test_converting_block_identifier_to_int(fork_mainnet_archive): 14 | """ 15 | Check that all inputs for web3 type `BlockIdentifier` can be converted to an integer 16 | """ 17 | 18 | degenbot.config.set_web3(fork_mainnet_archive.w3) 19 | 20 | # Known string literals 21 | assert isinstance(get_number_for_block_identifier("latest"), int) 22 | assert isinstance(get_number_for_block_identifier("earliest"), int) 23 | assert isinstance(get_number_for_block_identifier("pending"), int) 24 | assert isinstance(get_number_for_block_identifier("safe"), int) 25 | assert isinstance(get_number_for_block_identifier("finalized"), int) 26 | 27 | # BlockNumber 28 | assert isinstance( 29 | get_number_for_block_identifier(BlockNumber(1)), 30 | int, 31 | ) 32 | 33 | # Hash32 34 | assert isinstance( 35 | get_number_for_block_identifier(Hash32(int(1).to_bytes(length=32, byteorder="big"))), 36 | int, 37 | ) 38 | 39 | # HexStr 40 | assert isinstance( 41 | get_number_for_block_identifier( 42 | HexStr("0x" + int(128).to_bytes(32, byteorder="big").hex()) 43 | ), 44 | int, 45 | ) 46 | 47 | # HexBytes 48 | assert isinstance(get_number_for_block_identifier(HexBytes(1)), int) 49 | 50 | # int 51 | assert isinstance(get_number_for_block_identifier(1), int) 52 | 53 | for invalid_block_number in [-1, MAX_UINT256 + 1]: 54 | with pytest.raises(ValueError): 55 | get_number_for_block_identifier(invalid_block_number) 56 | 57 | for invalid_tag in ["Latest", "latest ", "next", "previous"]: 58 | with pytest.raises(ValueError): 59 | get_number_for_block_identifier(invalid_tag) # type: ignore[arg-type] 60 | 61 | 62 | def test_fee_calcs(): 63 | BASE_FEE = 100 * 10**9 64 | 65 | # EIP-1559 target is 50% full blocks, so a 50% full block should return the same base fee 66 | assert ( 67 | next_base_fee( 68 | parent_base_fee=BASE_FEE, 69 | parent_gas_used=15_000_000, 70 | parent_gas_limit=30_000_000, 71 | ) 72 | == BASE_FEE 73 | ) 74 | 75 | # Fee should be higher 76 | assert ( 77 | next_base_fee( 78 | parent_base_fee=BASE_FEE, 79 | parent_gas_used=20_000_000, 80 | parent_gas_limit=30_000_000, 81 | ) 82 | == 104166666666 83 | ) 84 | 85 | # Fee should be lower 86 | assert ( 87 | next_base_fee( 88 | parent_base_fee=BASE_FEE, 89 | parent_gas_used=10_000_000, 90 | parent_gas_limit=30_000_000, 91 | ) 92 | == 95833333334 93 | ) 94 | 95 | MIN_BASE_FEE = 95 * 10**9 96 | 97 | # Enforce minimum fee 98 | assert ( 99 | next_base_fee( 100 | parent_base_fee=BASE_FEE, 101 | parent_gas_used=0, 102 | parent_gas_limit=30_000_000, 103 | min_base_fee=MIN_BASE_FEE, 104 | ) 105 | == MIN_BASE_FEE 106 | ) 107 | -------------------------------------------------------------------------------- /tests/test_erc20_token.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from degenbot.config import set_web3 3 | from degenbot.erc20_token import EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE, Erc20Token 4 | from degenbot.fork.anvil_fork import AnvilFork 5 | from eth_utils.address import to_checksum_address 6 | from hexbytes import HexBytes 7 | 8 | VITALIK_ADDRESS = to_checksum_address("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045") 9 | WETH_ADDRESS = to_checksum_address("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") 10 | WBTC_ADDRESS = to_checksum_address("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599") 11 | 12 | 13 | @pytest.fixture 14 | def wbtc(ethereum_full_node_web3): 15 | set_web3(ethereum_full_node_web3) 16 | return Erc20Token(WBTC_ADDRESS) 17 | 18 | 19 | @pytest.fixture 20 | def weth(ethereum_full_node_web3): 21 | set_web3(ethereum_full_node_web3) 22 | return Erc20Token(WETH_ADDRESS) 23 | 24 | 25 | def test_erc20token_comparisons(wbtc, weth): 26 | assert weth != wbtc 27 | 28 | assert weth == WETH_ADDRESS 29 | assert weth == WETH_ADDRESS.lower() 30 | assert weth == WETH_ADDRESS.upper() 31 | assert weth == to_checksum_address(WETH_ADDRESS) 32 | assert weth == HexBytes(WETH_ADDRESS) 33 | 34 | assert wbtc == WBTC_ADDRESS 35 | assert wbtc == WBTC_ADDRESS.lower() 36 | assert wbtc == WBTC_ADDRESS.upper() 37 | assert wbtc == to_checksum_address(WBTC_ADDRESS) 38 | assert wbtc == HexBytes(WBTC_ADDRESS) 39 | 40 | assert weth > wbtc 41 | assert weth > WBTC_ADDRESS 42 | assert weth > WBTC_ADDRESS.lower() 43 | assert weth > WBTC_ADDRESS.upper() 44 | assert weth > to_checksum_address(WBTC_ADDRESS) 45 | assert weth > HexBytes(WBTC_ADDRESS) 46 | 47 | assert wbtc < weth 48 | assert wbtc < WETH_ADDRESS 49 | assert wbtc < WETH_ADDRESS.lower() 50 | assert wbtc < WETH_ADDRESS.upper() 51 | assert wbtc < to_checksum_address(WETH_ADDRESS) 52 | assert wbtc < HexBytes(WETH_ADDRESS) 53 | 54 | 55 | def test_non_compliant_tokens(ethereum_full_node_web3): 56 | set_web3(ethereum_full_node_web3) 57 | for token_address in [ 58 | "0x043942281890d4876D26BD98E2BB3F662635DFfb", 59 | "0x1da4858ad385cc377165A298CC2CE3fce0C5fD31", 60 | "0x9A2548335a639a58F4241b85B5Fc6c57185C428A", 61 | "0xC19B6A4Ac7C7Cc24459F08984Bbd09664af17bD1", 62 | "0xC36442b4a4522E871399CD717aBDD847Ab11FE88", 63 | "0xf5BF148Be50f6972124f223215478519A2787C8E", 64 | "0xfCf163B5C68bE47f702432F0f54B58Cd6E18D10B", 65 | "0x431ad2ff6a9C365805eBaD47Ee021148d6f7DBe0", 66 | "0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359", 67 | "0xEB9951021698B42e4399f9cBb6267Aa35F82D59D", 68 | "0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2", 69 | ]: 70 | Erc20Token(token_address) 71 | 72 | 73 | def test_erc20token_with_price_feed(ethereum_full_node_web3): 74 | set_web3(ethereum_full_node_web3) 75 | Erc20Token( 76 | address="0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", 77 | oracle_address="0x5f4ec3df9cbd43714fe2740f5e3616155c5b8419", 78 | ) 79 | 80 | 81 | def test_erc20token_functions(ethereum_full_node_web3): 82 | set_web3(ethereum_full_node_web3) 83 | weth = Erc20Token( 84 | address="0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", 85 | oracle_address="0x5f4ec3df9cbd43714fe2740f5e3616155c5b8419", 86 | ) 87 | weth.get_total_supply() 88 | weth.get_approval(VITALIK_ADDRESS, weth.address) 89 | weth.get_balance(VITALIK_ADDRESS) 90 | weth.update_price() 91 | 92 | 93 | def test_ether_placeholder(ethereum_full_node_web3): 94 | set_web3(ethereum_full_node_web3) 95 | ether = EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE() 96 | ether.get_balance(VITALIK_ADDRESS) 97 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v3_libraries/swap_math.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | from . import full_math as FullMath 4 | from . import sqrt_price_math as SqrtPriceMath 5 | 6 | 7 | def computeSwapStep( 8 | sqrtRatioCurrentX96: int, 9 | sqrtRatioTargetX96: int, 10 | liquidity: int, 11 | amountRemaining: int, 12 | feePips: int, 13 | ) -> Tuple[int, int, int, int]: 14 | zeroForOne: bool = sqrtRatioCurrentX96 >= sqrtRatioTargetX96 15 | exactIn: bool = amountRemaining >= 0 16 | 17 | if exactIn: 18 | amountRemainingLessFee: int = FullMath.mulDiv(amountRemaining, 10**6 - feePips, 10**6) 19 | amountIn = ( 20 | SqrtPriceMath.getAmount0Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, True) 21 | if zeroForOne 22 | else SqrtPriceMath.getAmount1Delta( 23 | sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, True 24 | ) 25 | ) 26 | if amountRemainingLessFee >= amountIn: 27 | sqrtRatioNextX96 = sqrtRatioTargetX96 28 | else: 29 | sqrtRatioNextX96 = SqrtPriceMath.getNextSqrtPriceFromInput( 30 | sqrtRatioCurrentX96, 31 | liquidity, 32 | amountRemainingLessFee, 33 | zeroForOne, 34 | ) 35 | else: 36 | amountOut = ( 37 | SqrtPriceMath.getAmount1Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, False) 38 | if zeroForOne 39 | else SqrtPriceMath.getAmount0Delta( 40 | sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, False 41 | ) 42 | ) 43 | if -amountRemaining >= amountOut: 44 | sqrtRatioNextX96 = sqrtRatioTargetX96 45 | else: 46 | sqrtRatioNextX96 = SqrtPriceMath.getNextSqrtPriceFromOutput( 47 | sqrtRatioCurrentX96, 48 | liquidity, 49 | -amountRemaining, 50 | zeroForOne, 51 | ) 52 | 53 | max: bool = sqrtRatioTargetX96 == sqrtRatioNextX96 54 | # get the input/output amounts 55 | if zeroForOne: 56 | amountIn = ( 57 | amountIn 58 | if (max and exactIn) 59 | else SqrtPriceMath.getAmount0Delta( 60 | sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, True 61 | ) 62 | ) 63 | amountOut = ( 64 | amountOut 65 | if (max and not exactIn) 66 | else SqrtPriceMath.getAmount1Delta( 67 | sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, False 68 | ) 69 | ) 70 | else: 71 | amountIn = ( 72 | amountIn 73 | if (max and exactIn) 74 | else SqrtPriceMath.getAmount1Delta( 75 | sqrtRatioCurrentX96, sqrtRatioNextX96, liquidity, True 76 | ) 77 | ) 78 | amountOut = ( 79 | amountOut 80 | if (max and not exactIn) 81 | else SqrtPriceMath.getAmount0Delta( 82 | sqrtRatioCurrentX96, sqrtRatioNextX96, liquidity, False 83 | ) 84 | ) 85 | 86 | # cap the output amount to not exceed the remaining output amount 87 | if not exactIn and (amountOut > -amountRemaining): 88 | amountOut = -amountRemaining 89 | 90 | if exactIn and (sqrtRatioNextX96 != sqrtRatioTargetX96): 91 | # we didn't reach the target, so take the remainder of the maximum input as fee 92 | feeAmount = amountRemaining - amountIn 93 | else: 94 | feeAmount = FullMath.mulDivRoundingUp(amountIn, feePips, 10**6 - feePips) 95 | 96 | return ( 97 | sqrtRatioNextX96, 98 | amountIn, 99 | amountOut, 100 | feeAmount, 101 | ) 102 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v3_libraries/tick_bitmap.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from typing import Dict, Tuple 3 | 4 | from ...constants import MAX_UINT8 5 | from ...exceptions import BitmapWordUnavailableError, EVMRevertError, MissingTickWordError 6 | from ...logging import logger 7 | from ..v3_dataclasses import UniswapV3BitmapAtWord 8 | from . import bit_math as BitMath 9 | 10 | 11 | def flipTick( 12 | tick_bitmap: Dict[int, "UniswapV3BitmapAtWord"], 13 | tick: int, 14 | tick_spacing: int, 15 | update_block: int | None = None, 16 | ) -> None: 17 | if not (tick % tick_spacing == 0): 18 | raise EVMRevertError("Tick not correctly spaced!") 19 | 20 | word_pos, bit_pos = position(int(Decimal(tick) // tick_spacing)) 21 | logger.debug(f"Flipping {tick=} @ {word_pos=}, {bit_pos=}") 22 | 23 | try: 24 | mask = 1 << bit_pos 25 | tick_bitmap[word_pos].bitmap ^= mask 26 | tick_bitmap[word_pos].block = update_block 27 | except KeyError: 28 | raise MissingTickWordError(f"Called flipTick on missing word={word_pos}") 29 | else: 30 | logger.debug(f"Flipped {tick=} @ {word_pos=}, {bit_pos=}") 31 | 32 | 33 | def position(tick: int) -> Tuple[int, int]: 34 | word_pos: int = tick >> 8 35 | bit_pos: int = tick % 256 36 | return (word_pos, bit_pos) 37 | 38 | 39 | def nextInitializedTickWithinOneWord( 40 | tick_bitmap: Dict[int, "UniswapV3BitmapAtWord"], 41 | tick: int, 42 | tick_spacing: int, 43 | less_than_or_equal: bool, 44 | ) -> Tuple[int, bool]: 45 | compressed = int( 46 | Decimal(tick) // tick_spacing 47 | ) # tick can be negative, use Decimal so floor division rounds to zero instead of negative infinity 48 | if tick < 0 and tick % tick_spacing != 0: 49 | compressed -= 1 # round towards negative infinity 50 | 51 | if less_than_or_equal: 52 | word_pos, bit_pos = position(compressed) 53 | 54 | try: 55 | bitmap_at_word = tick_bitmap[word_pos].bitmap 56 | except KeyError: 57 | raise BitmapWordUnavailableError(f"Bitmap at word {word_pos} unavailable.", word_pos) 58 | 59 | # all the 1s at or to the right of the current bitPos 60 | mask = 2 * (1 << bit_pos) - 1 61 | masked = bitmap_at_word & mask 62 | 63 | # if there are no initialized ticks to the right of or at the current tick, return rightmost in the word 64 | initialized_status = masked != 0 65 | # overflow/underflow is possible, but prevented externally by limiting both tickSpacing and tick 66 | next_tick = ( 67 | (compressed - (bit_pos - BitMath.mostSignificantBit(masked))) * tick_spacing 68 | if initialized_status 69 | else (compressed - (bit_pos)) * tick_spacing 70 | ) 71 | else: 72 | # start from the word of the next tick, since the current tick state doesn't matter 73 | word_pos, bit_pos = position(compressed + 1) 74 | 75 | try: 76 | bitmap_at_word = tick_bitmap[word_pos].bitmap 77 | except KeyError: 78 | raise BitmapWordUnavailableError(f"Bitmap at word {word_pos} unavailable.", word_pos) 79 | 80 | # all the 1s at or to the left of the bitPos 81 | mask = ~((1 << bit_pos) - 1) 82 | masked = bitmap_at_word & mask 83 | 84 | # if there are no initialized ticks to the left of the current tick, return leftmost in the word 85 | initialized_status = masked != 0 86 | # overflow/underflow is possible, but prevented externally by limiting both tickSpacing and tick 87 | next_tick = ( 88 | (compressed + 1 + (BitMath.leastSignificantBit(masked) - bit_pos)) * tick_spacing 89 | if initialized_status 90 | else (compressed + 1 + (MAX_UINT8 - bit_pos)) * tick_spacing 91 | ) 92 | 93 | return next_tick, initialized_status 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | .build 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Ruff 70 | .ruff_cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # poetry 100 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 101 | # This is especially recommended for binary packages to ensure reproducibility, and is more 102 | # commonly ignored for libraries. 103 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 104 | #poetry.lock 105 | 106 | # pdm 107 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 108 | #pdm.lock 109 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 110 | # in version control. 111 | # https://pdm.fming.dev/#use-with-ide 112 | .pdm.toml 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | *.env 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ 164 | 165 | # Pyenv 166 | .python-version 167 | 168 | # VSCode 169 | .vscode 170 | -------------------------------------------------------------------------------- /src/degenbot/exceptions.py: -------------------------------------------------------------------------------- 1 | # Base exception 2 | class DegenbotError(Exception): 3 | """ 4 | Base exception, intended as a generic exception and a base class for 5 | for all more-specific exceptions raised by various degenbot modules 6 | """ 7 | 8 | 9 | class DeprecationError(ValueError): 10 | """ 11 | Raised when a feature, class, method, etc. is deprecated. 12 | 13 | Subclasses `ValueError` instead of `Exception`, less likely to be ignored. 14 | """ 15 | 16 | 17 | # 1st level exceptions (derived from `DegenbotError`) 18 | class ArbitrageError(DegenbotError): 19 | """ 20 | Exception raised inside arbitrage helpers 21 | """ 22 | 23 | 24 | class Erc20TokenError(DegenbotError): 25 | """ 26 | Exception raised inside ERC-20 token helpers 27 | """ 28 | 29 | 30 | class EVMRevertError(DegenbotError): 31 | """ 32 | Raised when a simulated EVM contract operation would revert 33 | """ 34 | 35 | 36 | class ExternalServiceError(DegenbotError): 37 | """ 38 | Raised on errors resulting to some call to an external service 39 | """ 40 | 41 | 42 | class LiquidityPoolError(DegenbotError): 43 | """ 44 | Exception raised inside liquidity pool helpers 45 | """ 46 | 47 | 48 | class ManagerError(DegenbotError): 49 | """ 50 | Exception raised inside manager helpers 51 | """ 52 | 53 | 54 | class TransactionError(DegenbotError): 55 | """ 56 | Exception raised inside transaction simulation helpers 57 | """ 58 | 59 | 60 | # 2nd level exceptions for Arbitrage classes 61 | class ArbCalculationError(ArbitrageError): 62 | """ 63 | Raised when an arbitrage calculation fails 64 | """ 65 | 66 | 67 | class InvalidSwapPathError(ArbitrageError): 68 | """ 69 | Raised in arbitrage helper constructors when the provided path is invalid 70 | """ 71 | 72 | pass 73 | 74 | 75 | class ZeroLiquidityError(ArbitrageError): 76 | """ 77 | Raised by the arbitrage helper if a pool in the path has no liquidity in the direction of the proposed swap 78 | """ 79 | 80 | 81 | # 2nd level exceptions for Liquidity Pool classes 82 | class BitmapWordUnavailableError(LiquidityPoolError): 83 | """ 84 | Raised by the ported V3 swap function when the bitmap word is not available. 85 | This should be caught by the helper to perform automatic fetching, and should 86 | not be raised to the calling function 87 | """ 88 | 89 | 90 | class BrokenPool(LiquidityPoolError): 91 | """ 92 | Raised when an pool cannot or should not be built. 93 | """ 94 | 95 | 96 | class ExternalUpdateError(LiquidityPoolError): 97 | """ 98 | Raised when an external update does not pass sanity checks 99 | """ 100 | 101 | 102 | class InsufficientAmountOutError(LiquidityPoolError): 103 | """ 104 | Raised if an exact output swap results in fewer tokens than requested 105 | """ 106 | 107 | 108 | class MissingTickWordError(LiquidityPoolError): 109 | """ 110 | Raised by the TickBitmap library when calling for an operation on a word that 111 | should be available, but is not 112 | """ 113 | 114 | 115 | class NoPoolStateAvailable(LiquidityPoolError): 116 | """ 117 | Raised by the `restore_state_before_block` method when a previous pool 118 | state is not available. This can occur, e.g. if a pool was created in a 119 | block at or after a re-organization. 120 | """ 121 | 122 | 123 | class ZeroSwapError(LiquidityPoolError): 124 | """ 125 | Raised if a swap calculation resulted or would result in zero output 126 | """ 127 | 128 | 129 | # 2nd level exceptions for Transaction classes 130 | class LedgerError(TransactionError): 131 | """ 132 | Raised when the ledger does not align with the expected state 133 | """ 134 | 135 | 136 | # 2nd level exceptions for Uniswap Manager classes 137 | class PoolNotAssociated(ManagerError): 138 | """ 139 | Raised by a UniswapV2LiquidityPoolManager or UniswapV3LiquidityPoolManager 140 | class if a requested pool address is not associated with the DEX. 141 | """ 142 | -------------------------------------------------------------------------------- /tests/uniswap/v3/libraries/test_tick_math.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal, getcontext 2 | from math import floor, log 3 | 4 | import pytest 5 | from degenbot.constants import MAX_UINT160, MIN_UINT160 6 | from degenbot.exceptions import EVMRevertError 7 | from degenbot.uniswap.v3_libraries import TickMath 8 | 9 | # Tests adapted from Typescript tests on Uniswap V3 Github repo 10 | # ref: https://github.com/Uniswap/v3-core/blob/main/test/TickMath.spec.ts 11 | 12 | # Change the rounding method to match the BigNumber unit test at https://github.com/Uniswap/v3-core/blob/main/test/shared/utilities.ts 13 | # which specifies .integerValue(3), the 'ROUND_FLOOR' rounding method per https://mikemcl.github.io/bignumber.js/#bignumber 14 | getcontext().prec = 256 15 | getcontext().rounding = "ROUND_FLOOR" 16 | 17 | 18 | def encodePriceSqrt(reserve1: int, reserve0: int) -> int: 19 | """ 20 | Returns the sqrt price as a Q64.96 value 21 | """ 22 | return round((Decimal(reserve1) / Decimal(reserve0)).sqrt() * Decimal(2**96)) 23 | 24 | 25 | def test_getSqrtRatioAtTick() -> None: 26 | with pytest.raises(EVMRevertError, match="T"): 27 | TickMath.getSqrtRatioAtTick(TickMath.MIN_TICK - 1) 28 | 29 | with pytest.raises(EVMRevertError, match="T"): 30 | TickMath.getSqrtRatioAtTick(TickMath.MAX_TICK + 1) 31 | 32 | assert TickMath.getSqrtRatioAtTick(TickMath.MIN_TICK) == 4295128739 33 | 34 | assert TickMath.getSqrtRatioAtTick(TickMath.MIN_TICK + 1) == 4295343490 35 | 36 | assert ( 37 | TickMath.getSqrtRatioAtTick(TickMath.MAX_TICK - 1) 38 | == 1461373636630004318706518188784493106690254656249 39 | ) 40 | 41 | assert TickMath.getSqrtRatioAtTick(TickMath.MIN_TICK) < (encodePriceSqrt(1, 2**127)) 42 | 43 | assert TickMath.getSqrtRatioAtTick(TickMath.MAX_TICK) > encodePriceSqrt(2**127, 1) 44 | 45 | assert ( 46 | TickMath.getSqrtRatioAtTick(TickMath.MAX_TICK) 47 | == 1461446703485210103287273052203988822378723970342 48 | ) 49 | 50 | 51 | def test_minSqrtRatio() -> None: 52 | min = TickMath.getSqrtRatioAtTick(TickMath.MIN_TICK) 53 | assert min == TickMath.MIN_SQRT_RATIO 54 | 55 | 56 | def test_maxSqrtRatio() -> None: 57 | max = TickMath.getSqrtRatioAtTick(TickMath.MAX_TICK) 58 | assert max == TickMath.MAX_SQRT_RATIO 59 | 60 | 61 | def test_getTickAtSqrtRatio() -> None: 62 | with pytest.raises(EVMRevertError, match="Not a valid uint160"): 63 | TickMath.getTickAtSqrtRatio(MIN_UINT160 - 1) 64 | 65 | with pytest.raises(EVMRevertError, match="Not a valid uint160"): 66 | TickMath.getTickAtSqrtRatio(MAX_UINT160 + 1) 67 | 68 | with pytest.raises(EVMRevertError, match="R"): 69 | TickMath.getTickAtSqrtRatio(TickMath.MIN_SQRT_RATIO - 1) 70 | 71 | with pytest.raises(EVMRevertError, match="R"): 72 | TickMath.getTickAtSqrtRatio(TickMath.MAX_SQRT_RATIO) 73 | 74 | assert (TickMath.getTickAtSqrtRatio(TickMath.MIN_SQRT_RATIO)) == (TickMath.MIN_TICK) 75 | assert (TickMath.getTickAtSqrtRatio(4295343490)) == (TickMath.MIN_TICK + 1) 76 | 77 | assert (TickMath.getTickAtSqrtRatio(1461373636630004318706518188784493106690254656249)) == ( 78 | TickMath.MAX_TICK - 1 79 | ) 80 | assert (TickMath.getTickAtSqrtRatio(TickMath.MAX_SQRT_RATIO - 1)) == TickMath.MAX_TICK - 1 81 | 82 | for ratio in [ 83 | TickMath.MIN_SQRT_RATIO, 84 | encodePriceSqrt((10) ** (12), 1), 85 | encodePriceSqrt((10) ** (6), 1), 86 | encodePriceSqrt(1, 64), 87 | encodePriceSqrt(1, 8), 88 | encodePriceSqrt(1, 2), 89 | encodePriceSqrt(1, 1), 90 | encodePriceSqrt(2, 1), 91 | encodePriceSqrt(8, 1), 92 | encodePriceSqrt(64, 1), 93 | encodePriceSqrt(1, (10) ** (6)), 94 | encodePriceSqrt(1, (10) ** (12)), 95 | TickMath.MAX_SQRT_RATIO - 1, 96 | ]: 97 | math_result = floor(log(((ratio / 2**96) ** 2), 1.0001)) 98 | result = TickMath.getTickAtSqrtRatio(ratio) 99 | abs_diff = abs(result - math_result) 100 | assert abs_diff <= 1 101 | 102 | tick = TickMath.getTickAtSqrtRatio(ratio) 103 | ratio_of_tick = TickMath.getSqrtRatioAtTick(tick) 104 | ratio_of_tick_plus_one = TickMath.getSqrtRatioAtTick(tick + 1) 105 | assert ratio >= ratio_of_tick 106 | assert ratio < ratio_of_tick_plus_one 107 | -------------------------------------------------------------------------------- /tests/uniswap/v3/libraries/test_full_math.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import pytest 4 | from degenbot.constants import MAX_UINT256 5 | from degenbot.exceptions import EVMRevertError 6 | from degenbot.uniswap.v3_libraries import FullMath 7 | from degenbot.uniswap.v3_libraries.constants import Q128 8 | 9 | # Tests adapted from Typescript tests on Uniswap V3 Github repo 10 | # ref: https://github.com/Uniswap/v3-core/blob/main/test/FullMath.spec.ts 11 | 12 | 13 | def test_mulDiv(): 14 | ### ---------------------------------------------------- 15 | ### FullMath tests 16 | ### ---------------------------------------------------- 17 | 18 | # mulDiv tests 19 | with pytest.raises(EVMRevertError): 20 | # this test should fail 21 | FullMath.mulDiv(Q128, 5, 0) 22 | 23 | with pytest.raises(EVMRevertError): 24 | # this test should fail 25 | FullMath.mulDiv(Q128, Q128, 0) 26 | 27 | with pytest.raises(EVMRevertError): 28 | # this test should fail 29 | FullMath.mulDiv(Q128, Q128, 1) 30 | 31 | with pytest.raises(EVMRevertError): 32 | # this test should fail 33 | FullMath.mulDiv(MAX_UINT256, MAX_UINT256, MAX_UINT256 - 1) 34 | 35 | assert FullMath.mulDiv(MAX_UINT256, MAX_UINT256, MAX_UINT256) == MAX_UINT256 36 | 37 | assert ( 38 | FullMath.mulDiv( 39 | Q128, 40 | 50 * Q128 // 100, # 0.5x 41 | 150 * Q128 // 100, # 1.5x 42 | ) 43 | == Q128 // 3 44 | ) 45 | 46 | assert FullMath.mulDiv(Q128, 35 * Q128, 8 * Q128) == 4375 * Q128 // 1000 47 | 48 | assert ( 49 | FullMath.mulDiv( 50 | Q128, 51 | 1000 * Q128, 52 | 3000 * Q128, 53 | ) 54 | == Q128 // 3 55 | ) 56 | 57 | with pytest.raises(EVMRevertError): 58 | FullMath.mulDiv(-1, Q128, Q128) 59 | 60 | with pytest.raises(EVMRevertError): 61 | FullMath.mulDiv(Q128, -1, Q128) 62 | 63 | 64 | def test_mulDivRoundingUp(): 65 | with pytest.raises(EVMRevertError): 66 | FullMath.mulDivRoundingUp(Q128, 5, 0) 67 | 68 | with pytest.raises(EVMRevertError): 69 | FullMath.mulDivRoundingUp(Q128, Q128, 0) 70 | 71 | with pytest.raises(EVMRevertError): 72 | FullMath.mulDivRoundingUp(Q128, Q128, 1) 73 | 74 | with pytest.raises(EVMRevertError): 75 | FullMath.mulDivRoundingUp(MAX_UINT256, MAX_UINT256, MAX_UINT256 - 1) 76 | 77 | with pytest.raises(EVMRevertError): 78 | FullMath.mulDivRoundingUp( 79 | 535006138814359, 80 | 432862656469423142931042426214547535783388063929571229938474969, 81 | 2, 82 | ) 83 | 84 | with pytest.raises(EVMRevertError): 85 | FullMath.mulDivRoundingUp( 86 | 115792089237316195423570985008687907853269984659341747863450311749907997002549, 87 | 115792089237316195423570985008687907853269984659341747863450311749907997002550, 88 | 115792089237316195423570985008687907853269984653042931687443039491902864365164, 89 | ) 90 | 91 | # all max inputs 92 | assert FullMath.mulDivRoundingUp(MAX_UINT256, MAX_UINT256, MAX_UINT256) == MAX_UINT256 93 | 94 | # accurate without phantom overflow 95 | assert ( 96 | FullMath.mulDivRoundingUp( 97 | Q128, 98 | 50 * Q128 // 100, 99 | 150 * Q128 // 100, 100 | ) 101 | == Q128 // 3 + 1 102 | ) 103 | 104 | # accurate with phantom overflow 105 | assert FullMath.mulDivRoundingUp(Q128, 35 * Q128, 8 * Q128) == 4375 * Q128 // 1000 106 | 107 | # accurate with phantom overflow and repeating decimal 108 | assert ( 109 | FullMath.mulDivRoundingUp( 110 | Q128, 111 | 1000 * Q128, 112 | 3000 * Q128, 113 | ) 114 | == Q128 // 3 + 1 115 | ) 116 | 117 | def pseudoRandomBigNumber() -> int: 118 | return int(MAX_UINT256 * random.random()) 119 | 120 | def floored(x, y, d) -> int: 121 | return FullMath.mulDiv(x, y, d) 122 | 123 | def ceiled(x, y, d) -> int: 124 | return FullMath.mulDivRoundingUp(x, y, d) 125 | 126 | for i in range(1000): 127 | # override x, y for first two runs to cover the x == 0 and y == 0 cases 128 | x = pseudoRandomBigNumber() if i != 0 else 0 129 | y = pseudoRandomBigNumber() if i != 1 else 0 130 | d = pseudoRandomBigNumber() 131 | 132 | if x == 0 or y == 0: 133 | assert floored(x, y, d) == 0 134 | assert ceiled(x, y, d) == 0 135 | elif x * y // d > MAX_UINT256: 136 | with pytest.raises(EVMRevertError): 137 | # this test should fail 138 | floored(x, y, d) 139 | with pytest.raises(EVMRevertError): 140 | # this test should fail 141 | ceiled(x, y, d) 142 | else: 143 | assert floored(x, y, d) == x * y // d 144 | assert ceiled(x, y, d) == x * y // d + (1 if (x * y % d > 0) else 0) 145 | -------------------------------------------------------------------------------- /src/degenbot/baseclasses.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import dataclasses 3 | from typing import TYPE_CHECKING, Any, Iterator, Protocol, Sequence, Set 4 | 5 | from eth_typing import ChecksumAddress 6 | 7 | if TYPE_CHECKING: 8 | from .erc20_token import Erc20Token 9 | 10 | 11 | class Message: 12 | """ 13 | A message sent from a `Publisher` to a `Subscriber` 14 | """ 15 | 16 | 17 | class Publisher(Protocol): 18 | """ 19 | Can publish updates and accept subscriptions. 20 | """ 21 | 22 | _subscribers: Set["Subscriber"] 23 | 24 | 25 | class Subscriber(Protocol): 26 | """ 27 | Can be notified via the `notify()` method 28 | """ 29 | 30 | @abc.abstractmethod 31 | def notify(self, publisher: "Publisher", message: "Message") -> None: 32 | """ 33 | Deliver `message` from `publisher`. 34 | """ 35 | 36 | 37 | class BaseArbitrage: 38 | id: str 39 | gas_estimate: int 40 | swap_pools: Sequence["BaseLiquidityPool"] 41 | 42 | 43 | class BaseManager: 44 | """ 45 | Base class for managers that generate, track and distribute various helper classes 46 | """ 47 | 48 | 49 | class BasePoolUpdate: ... 50 | 51 | 52 | class BasePoolState: 53 | pool: ChecksumAddress 54 | 55 | 56 | class BaseSimulationResult: ... 57 | 58 | 59 | @dataclasses.dataclass(slots=True, frozen=True) 60 | class UniswapSimulationResult(BaseSimulationResult): 61 | amount0_delta: int 62 | amount1_delta: int 63 | initial_state: BasePoolState 64 | final_state: BasePoolState 65 | 66 | 67 | class BaseLiquidityPool(abc.ABC, Publisher): 68 | address: ChecksumAddress 69 | name: str 70 | state: BasePoolState 71 | tokens: Sequence["Erc20Token"] 72 | _subscribers: Set[Subscriber] 73 | 74 | def __eq__(self, other: Any) -> bool: 75 | if isinstance(other, BaseLiquidityPool): 76 | return self.address == other.address 77 | elif isinstance(other, bytes): 78 | return self.address.lower() == other.hex().lower() 79 | elif isinstance(other, str): 80 | return self.address.lower() == other.lower() 81 | else: 82 | return NotImplemented 83 | 84 | def __lt__(self, other: Any) -> bool: 85 | if isinstance(other, BaseLiquidityPool): 86 | return self.address < other.address 87 | elif isinstance(other, bytes): 88 | return self.address.lower() < other.hex().lower() 89 | elif isinstance(other, str): 90 | return self.address.lower() < other.lower() 91 | else: 92 | return NotImplemented 93 | 94 | def __gt__(self, other: Any) -> bool: 95 | if isinstance(other, BaseLiquidityPool): 96 | return self.address > other.address 97 | elif isinstance(other, bytes): 98 | return self.address.lower() > other.hex().lower() 99 | elif isinstance(other, str): 100 | return self.address.lower() > other.lower() 101 | else: 102 | return NotImplemented 103 | 104 | def __hash__(self) -> int: 105 | return hash(self.address) 106 | 107 | def __str__(self) -> str: 108 | return self.name 109 | 110 | def _notify_subscribers(self: Publisher, message: Message) -> None: 111 | for subscriber in self._subscribers: 112 | subscriber.notify(self, message) 113 | 114 | def get_arbitrage_helpers(self: Publisher) -> Iterator[BaseArbitrage]: 115 | return ( 116 | subscriber 117 | for subscriber in self._subscribers 118 | if isinstance(subscriber, (BaseArbitrage)) 119 | ) 120 | 121 | def subscribe(self: Publisher, subscriber: Subscriber) -> None: 122 | self._subscribers.add(subscriber) 123 | 124 | def unsubscribe(self: Publisher, subscriber: Subscriber) -> None: 125 | self._subscribers.discard(subscriber) 126 | 127 | 128 | class BaseToken: 129 | address: ChecksumAddress 130 | symbol: str 131 | name: str 132 | decimals: int 133 | 134 | def __eq__(self, other: Any) -> bool: 135 | if isinstance(other, BaseToken): 136 | return self.address == other.address 137 | elif isinstance(other, bytes): 138 | return self.address.lower() == other.hex().lower() 139 | elif isinstance(other, str): 140 | return self.address.lower() == other.lower() 141 | else: 142 | return NotImplemented 143 | 144 | def __lt__(self, other: Any) -> bool: 145 | if isinstance(other, BaseToken): 146 | return self.address < other.address 147 | elif isinstance(other, bytes): 148 | return self.address.lower() < other.hex().lower() 149 | elif isinstance(other, str): 150 | return self.address.lower() < other.lower() 151 | else: 152 | return NotImplemented 153 | 154 | def __gt__(self, other: Any) -> bool: 155 | if isinstance(other, BaseToken): 156 | return self.address > other.address 157 | elif isinstance(other, bytes): 158 | return self.address.lower() > other.hex().lower() 159 | elif isinstance(other, str): 160 | return self.address.lower() > other.lower() 161 | else: 162 | return NotImplemented 163 | 164 | def __hash__(self) -> int: 165 | return hash(self.address) 166 | 167 | def __str__(self) -> str: 168 | return self.symbol 169 | 170 | 171 | class BaseTransaction: ... 172 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v3_libraries/sqrt_price_math.py: -------------------------------------------------------------------------------- 1 | from ...constants import MIN_UINT160 2 | from ...exceptions import EVMRevertError 3 | from . import full_math as FullMath 4 | from . import unsafe_math as UnsafeMath 5 | from .constants import Q96, Q96_RESOLUTION 6 | from .functions import to_int256, to_uint160 7 | 8 | 9 | def getAmount0Delta( 10 | sqrtRatioAX96: int, 11 | sqrtRatioBX96: int, 12 | liquidity: int, 13 | roundUp: bool | None = None, 14 | ) -> int: 15 | # The Solidity function is overloaded with respect to `roundUp`. 16 | # ref: https://github.com/Uniswap/v3-core/blob/main/contracts/libraries/SqrtPriceMath.sol 17 | 18 | if roundUp is not None: 19 | if sqrtRatioAX96 > sqrtRatioBX96: 20 | sqrtRatioAX96, sqrtRatioBX96 = sqrtRatioBX96, sqrtRatioAX96 21 | 22 | numerator1 = liquidity << Q96_RESOLUTION 23 | numerator2 = sqrtRatioBX96 - sqrtRatioAX96 24 | 25 | if not (sqrtRatioAX96 > 0): 26 | raise EVMRevertError("require sqrtRatioAX96 > 0") 27 | 28 | return ( 29 | UnsafeMath.divRoundingUp( 30 | FullMath.mulDivRoundingUp(numerator1, numerator2, sqrtRatioBX96), sqrtRatioAX96 31 | ) 32 | if roundUp 33 | else FullMath.mulDiv(numerator1, numerator2, sqrtRatioBX96) // sqrtRatioAX96 34 | ) 35 | else: 36 | return to_int256( 37 | to_int256(-getAmount0Delta(sqrtRatioAX96, sqrtRatioBX96, -liquidity, False)) 38 | if liquidity < 0 39 | else to_int256(getAmount0Delta(sqrtRatioAX96, sqrtRatioBX96, liquidity, True)) 40 | ) 41 | 42 | 43 | def getAmount1Delta( 44 | sqrtRatioAX96: int, 45 | sqrtRatioBX96: int, 46 | liquidity: int, 47 | roundUp: bool | None = None, 48 | ) -> int: 49 | # The Solidity function is overloaded with respect to `roundUp`. 50 | # ref: https://github.com/Uniswap/v3-core/blob/main/contracts/libraries/SqrtPriceMath.sol 51 | 52 | if roundUp is not None: 53 | if sqrtRatioAX96 > sqrtRatioBX96: 54 | sqrtRatioAX96, sqrtRatioBX96 = sqrtRatioBX96, sqrtRatioAX96 55 | 56 | return ( 57 | FullMath.mulDivRoundingUp(liquidity, sqrtRatioBX96 - sqrtRatioAX96, Q96) 58 | if roundUp 59 | else FullMath.mulDiv(liquidity, sqrtRatioBX96 - sqrtRatioAX96, Q96) 60 | ) 61 | else: 62 | return to_int256( 63 | to_int256(-getAmount1Delta(sqrtRatioAX96, sqrtRatioBX96, -liquidity, False)) 64 | if liquidity < 0 65 | else to_int256(getAmount1Delta(sqrtRatioAX96, sqrtRatioBX96, liquidity, True)) 66 | ) 67 | 68 | 69 | def getNextSqrtPriceFromAmount0RoundingUp( 70 | sqrtPX96: int, 71 | liquidity: int, 72 | amount: int, 73 | add: bool, 74 | ) -> int: 75 | if amount == 0: 76 | return sqrtPX96 77 | 78 | numerator1 = liquidity << Q96_RESOLUTION 79 | 80 | if add: 81 | return ( 82 | FullMath.mulDivRoundingUp(numerator1, sqrtPX96, denominator) 83 | if ( 84 | (product := amount * sqrtPX96) // amount == sqrtPX96 85 | and (denominator := numerator1 + product) >= numerator1 86 | ) 87 | else UnsafeMath.divRoundingUp(numerator1, numerator1 // sqrtPX96 + amount) 88 | ) 89 | else: 90 | product = amount * sqrtPX96 91 | if not (product // amount == sqrtPX96 and numerator1 > product): 92 | raise EVMRevertError("product / amount == sqrtPX96 && numerator1 > product") 93 | 94 | denominator = numerator1 - product 95 | return to_uint160(FullMath.mulDivRoundingUp(numerator1, sqrtPX96, denominator)) 96 | 97 | 98 | def getNextSqrtPriceFromAmount1RoundingDown( 99 | sqrtPX96: int, 100 | liquidity: int, 101 | amount: int, 102 | add: bool, 103 | ) -> int: 104 | if add: 105 | quotient = ( 106 | (amount << Q96_RESOLUTION) // liquidity 107 | if amount <= 2**160 - 1 108 | else FullMath.mulDiv(amount, Q96, liquidity) 109 | ) 110 | return to_uint160(sqrtPX96 + quotient) 111 | else: 112 | quotient = ( 113 | UnsafeMath.divRoundingUp(amount << Q96_RESOLUTION, liquidity) 114 | if amount <= (2**160) - 1 115 | else FullMath.mulDivRoundingUp(amount, Q96, liquidity) 116 | ) 117 | 118 | if not (sqrtPX96 > quotient): 119 | raise EVMRevertError("require sqrtPX96 > quotient") 120 | 121 | # always fits 160 bits 122 | return sqrtPX96 - quotient 123 | 124 | 125 | def getNextSqrtPriceFromInput( 126 | sqrtPX96: int, 127 | liquidity: int, 128 | amountIn: int, 129 | zeroForOne: bool, 130 | ) -> int: 131 | if not (sqrtPX96 > MIN_UINT160): 132 | raise EVMRevertError("sqrtPX96 must be greater than 0") 133 | 134 | if not (liquidity > MIN_UINT160): 135 | raise EVMRevertError("liquidity must be greater than 0") 136 | 137 | # round to make sure that we don't pass the target price 138 | return ( 139 | getNextSqrtPriceFromAmount0RoundingUp(sqrtPX96, liquidity, amountIn, True) 140 | if zeroForOne 141 | else getNextSqrtPriceFromAmount1RoundingDown(sqrtPX96, liquidity, amountIn, True) 142 | ) 143 | 144 | 145 | def getNextSqrtPriceFromOutput( 146 | sqrtPX96: int, 147 | liquidity: int, 148 | amountOut: int, 149 | zeroForOne: bool, 150 | ) -> int: 151 | if not (sqrtPX96 > 0): 152 | raise EVMRevertError 153 | 154 | if not (liquidity > 0): 155 | raise EVMRevertError 156 | 157 | # round to make sure that we pass the target price 158 | return ( 159 | getNextSqrtPriceFromAmount1RoundingDown(sqrtPX96, liquidity, amountOut, False) 160 | if zeroForOne 161 | else getNextSqrtPriceFromAmount0RoundingUp(sqrtPX96, liquidity, amountOut, False) 162 | ) 163 | -------------------------------------------------------------------------------- /src/degenbot/transaction/simulation_ledger.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from eth_typing import ChecksumAddress 4 | from eth_utils.address import to_checksum_address 5 | 6 | from ..logging import logger 7 | from ..erc20_token import Erc20Token 8 | 9 | 10 | class SimulationLedger: 11 | """ 12 | A dictionary-like class for tracking token balances across addresses. 13 | 14 | Token balances are organized first by the holding address, then by the 15 | token contract address. 16 | """ 17 | 18 | def __init__(self) -> None: 19 | # Entries are recorded as a dict-of-dicts, keyed by address, then by 20 | # token address 21 | self._balances: Dict[ 22 | ChecksumAddress, # address holding balance 23 | Dict[ 24 | ChecksumAddress, # token address 25 | int, # balance 26 | ], 27 | ] = dict() 28 | 29 | def adjust( 30 | self, 31 | address: ChecksumAddress | str, 32 | token: Erc20Token | ChecksumAddress | str, 33 | amount: int, 34 | ) -> None: 35 | """ 36 | Apply an adjustment to the balance for a token held by an address. 37 | 38 | The amount can be positive (credit) or negative (debit). The method 39 | checksums all addresses prior to use. 40 | 41 | Parameters 42 | ---------- 43 | address: str | ChecksumAddress 44 | The address holding the token balance. 45 | token: Erc20Token | str | ChecksumAddress 46 | The token being held. May be passed as an address or an ``Erc20Token`` 47 | amount: int 48 | The amount to adjust. May be negative or positive. 49 | 50 | Returns 51 | ------- 52 | None 53 | 54 | Raises 55 | ------ 56 | ValueError 57 | If inputs did not match the expected types. 58 | """ 59 | 60 | _token_address: ChecksumAddress 61 | if isinstance(token, Erc20Token): 62 | _token_address = token.address 63 | else: 64 | _token_address = to_checksum_address(token) 65 | 66 | _address = to_checksum_address(address) 67 | 68 | address_balance: Dict[ChecksumAddress, int] 69 | try: 70 | address_balance = self._balances[_address] 71 | except KeyError: 72 | address_balance = {} 73 | self._balances[_address] = address_balance 74 | 75 | logger.debug(f"BALANCE: {_address} {'+' if amount > 0 else ''}{amount} {_token_address}") 76 | 77 | try: 78 | address_balance[_token_address] 79 | except KeyError: 80 | address_balance[_token_address] = 0 81 | finally: 82 | address_balance[_token_address] += amount 83 | if address_balance[_token_address] == 0: 84 | del address_balance[_token_address] 85 | if not address_balance: 86 | del self._balances[_address] 87 | 88 | def token_balance( 89 | self, 90 | address: ChecksumAddress | str, 91 | token: Erc20Token | ChecksumAddress | str, 92 | ) -> int: 93 | """ 94 | Get the balance for a given address and token. 95 | 96 | The method checksums all addresses prior to use. 97 | 98 | Parameters 99 | ---------- 100 | address: str | ChecksumAddress 101 | The address holding the token balance. 102 | token: Erc20Token | str | ChecksumAddress 103 | The token being held. May be passed as an address or an ``Erc20Token`` 104 | 105 | Returns 106 | ------- 107 | int 108 | The balance of ``token`` at ``address`` 109 | 110 | Raises 111 | ------ 112 | ValueError 113 | If inputs did not match the expected types. 114 | """ 115 | 116 | _address = to_checksum_address(address) 117 | 118 | if isinstance(token, Erc20Token): 119 | _token_address = token.address 120 | else: 121 | _token_address = to_checksum_address(token) 122 | 123 | address_balances: Dict[ChecksumAddress, int] 124 | try: 125 | address_balances = self._balances[_address] 126 | except KeyError: 127 | address_balances = {} 128 | 129 | return address_balances.get(_token_address, 0) 130 | 131 | def transfer( 132 | self, 133 | token: Erc20Token | ChecksumAddress | str, 134 | amount: int, 135 | from_addr: ChecksumAddress | str, 136 | to_addr: ChecksumAddress | str, 137 | ) -> None: 138 | """ 139 | Transfer a balance between addresses. 140 | 141 | The method checksums all addresses prior to use. 142 | 143 | Parameters 144 | ---------- 145 | token: Erc20Token | str | ChecksumAddress 146 | The token being held. May be passed as an address or an ``Erc20Token`` 147 | amount: int 148 | The balance to transfer. 149 | from_addr: str | ChecksumAddress 150 | The address holding the token balance. 151 | to_addr: str | ChecksumAddress 152 | The address holding the token balance. 153 | 154 | Returns 155 | ------- 156 | None 157 | 158 | Raises 159 | ------ 160 | ValueError 161 | If inputs did not match the expected types. 162 | """ 163 | 164 | if isinstance(token, Erc20Token): 165 | _token_address = token.address 166 | else: 167 | _token_address = to_checksum_address(token) 168 | 169 | self.adjust( 170 | address=from_addr, 171 | token=_token_address, 172 | amount=-amount, 173 | ) 174 | self.adjust( 175 | address=to_addr, 176 | token=_token_address, 177 | amount=amount, 178 | ) 179 | -------------------------------------------------------------------------------- /src/degenbot/chainlink.py: -------------------------------------------------------------------------------- 1 | import ujson 2 | from eth_utils.address import to_checksum_address 3 | from web3.contract.contract import Contract 4 | 5 | from . import config 6 | 7 | CHAINLINK_PRICE_FEED_ABI = ujson.loads( 8 | '[{"inputs":[{"internalType":"address","name":"_aggregator","type":"address"},{"internalType":"address","name":"_accessController","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"int256","name":"current","type":"int256"},{"indexed":true,"internalType":"uint256","name":"roundId","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"updatedAt","type":"uint256"}],"name":"AnswerUpdated","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"uint256","name":"roundId","type":"uint256"},{"indexed":true,"internalType":"address","name":"startedBy","type":"address"},{"indexed":false,"internalType":"uint256","name":"startedAt","type":"uint256"}],"name":"NewRound","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"}],"name":"OwnershipTransferRequested","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"}],"name":"OwnershipTransferred","type":"event"},{"inputs":[],"name":"acceptOwnership","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"accessController","outputs":[{"internalType":"contract AccessControllerInterface","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"aggregator","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"_aggregator","type":"address"}],"name":"confirmAggregator","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"decimals","outputs":[{"internalType":"uint8","name":"","type":"uint8"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"description","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"_roundId","type":"uint256"}],"name":"getAnswer","outputs":[{"internalType":"int256","name":"","type":"int256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint80","name":"_roundId","type":"uint80"}],"name":"getRoundData","outputs":[{"internalType":"uint80","name":"roundId","type":"uint80"},{"internalType":"int256","name":"answer","type":"int256"},{"internalType":"uint256","name":"startedAt","type":"uint256"},{"internalType":"uint256","name":"updatedAt","type":"uint256"},{"internalType":"uint80","name":"answeredInRound","type":"uint80"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"_roundId","type":"uint256"}],"name":"getTimestamp","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"latestAnswer","outputs":[{"internalType":"int256","name":"","type":"int256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"latestRound","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"latestRoundData","outputs":[{"internalType":"uint80","name":"roundId","type":"uint80"},{"internalType":"int256","name":"answer","type":"int256"},{"internalType":"uint256","name":"startedAt","type":"uint256"},{"internalType":"uint256","name":"updatedAt","type":"uint256"},{"internalType":"uint80","name":"answeredInRound","type":"uint80"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"latestTimestamp","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"owner","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint16","name":"","type":"uint16"}],"name":"phaseAggregators","outputs":[{"internalType":"contract AggregatorV2V3Interface","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"phaseId","outputs":[{"internalType":"uint16","name":"","type":"uint16"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"_aggregator","type":"address"}],"name":"proposeAggregator","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"proposedAggregator","outputs":[{"internalType":"contract AggregatorV2V3Interface","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint80","name":"_roundId","type":"uint80"}],"name":"proposedGetRoundData","outputs":[{"internalType":"uint80","name":"roundId","type":"uint80"},{"internalType":"int256","name":"answer","type":"int256"},{"internalType":"uint256","name":"startedAt","type":"uint256"},{"internalType":"uint256","name":"updatedAt","type":"uint256"},{"internalType":"uint80","name":"answeredInRound","type":"uint80"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"proposedLatestRoundData","outputs":[{"internalType":"uint80","name":"roundId","type":"uint80"},{"internalType":"int256","name":"answer","type":"int256"},{"internalType":"uint256","name":"startedAt","type":"uint256"},{"internalType":"uint256","name":"updatedAt","type":"uint256"},{"internalType":"uint80","name":"answeredInRound","type":"uint80"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"_accessController","type":"address"}],"name":"setController","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"_to","type":"address"}],"name":"transferOwnership","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"version","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}]' 9 | ) 10 | 11 | 12 | class ChainlinkPriceContract: 13 | """ 14 | Represents an on-chain Chainlink price oracle. 15 | Attribute `price` is decimal-corrected and represents the nominal token 16 | price in USD (e.g. 1 DAI = 1.0 USD) 17 | """ 18 | 19 | def __init__(self, address: str): 20 | self.address = to_checksum_address(address) 21 | self._decimals: int = self._w3_contract.functions.decimals().call() 22 | self.update_price() 23 | 24 | @property 25 | def _w3_contract(self) -> Contract: 26 | return config.get_web3().eth.contract( 27 | address=self.address, 28 | abi=CHAINLINK_PRICE_FEED_ABI, 29 | ) 30 | 31 | def update_price( 32 | self, 33 | ) -> float: 34 | self.price: float = self._w3_contract.functions.latestRoundData().call()[1] / ( 35 | 10**self._decimals 36 | ) 37 | return self.price 38 | -------------------------------------------------------------------------------- /tests/uniswap/v3/libraries/test_swap_math.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal, getcontext 2 | 3 | from degenbot.uniswap.v3_libraries import SqrtPriceMath, SwapMath 4 | 5 | # Tests adapted from Typescript tests on Uniswap V3 Github repo 6 | # ref: https://github.com/Uniswap/v3-core/blob/main/test/SwapMath.spec.ts 7 | 8 | 9 | # Change the rounding method to match the BigNumber unit test at https://github.com/Uniswap/v3-core/blob/main/test/shared/utilities.ts 10 | # which specifies .integerValue(3), the 'ROUND_FLOOR' rounding method per https://mikemcl.github.io/bignumber.js/#bignumber 11 | getcontext().prec = 256 12 | getcontext().rounding = "ROUND_FLOOR" 13 | 14 | 15 | def expandTo18Decimals(x: int): 16 | return x * 10**18 17 | 18 | 19 | def encodePriceSqrt(reserve1: int, reserve0: int): 20 | """ 21 | Returns the sqrt price as a Q64.96 value 22 | """ 23 | return round((Decimal(reserve1) / Decimal(reserve0)).sqrt() * Decimal(2**96)) 24 | 25 | 26 | def test_computeSwapStep(): 27 | # exact amount in that gets capped at price target in one for zero 28 | price = encodePriceSqrt(1, 1) 29 | priceTarget = encodePriceSqrt(101, 100) 30 | liquidity = expandTo18Decimals(2) 31 | amount = expandTo18Decimals(1) 32 | fee = 600 33 | zeroForOne = False 34 | 35 | sqrtQ, amountIn, amountOut, feeAmount = SwapMath.computeSwapStep( 36 | price, priceTarget, liquidity, amount, fee 37 | ) 38 | 39 | assert amountIn == 9975124224178055 40 | assert feeAmount == 5988667735148 41 | assert amountOut == 9925619580021728 42 | assert amountIn + feeAmount < amount 43 | 44 | priceAfterWholeInputAmount = SqrtPriceMath.getNextSqrtPriceFromInput( 45 | price, liquidity, amount, zeroForOne 46 | ) 47 | 48 | assert sqrtQ == priceTarget 49 | assert sqrtQ < priceAfterWholeInputAmount 50 | 51 | # exact amount out that gets capped at price target in one for zero 52 | price = encodePriceSqrt(1, 1) 53 | priceTarget = encodePriceSqrt(101, 100) 54 | liquidity = expandTo18Decimals(2) 55 | amount = -expandTo18Decimals(1) 56 | fee = 600 57 | zeroForOne = False 58 | 59 | sqrtQ, amountIn, amountOut, feeAmount = SwapMath.computeSwapStep( 60 | price, priceTarget, liquidity, amount, fee 61 | ) 62 | 63 | assert amountIn == 9975124224178055 64 | assert feeAmount == 5988667735148 65 | assert amountOut == 9925619580021728 66 | assert amountOut < -amount 67 | 68 | priceAfterWholeOutputAmount = SqrtPriceMath.getNextSqrtPriceFromOutput( 69 | price, liquidity, -amount, zeroForOne 70 | ) 71 | 72 | assert sqrtQ == priceTarget 73 | assert sqrtQ < priceAfterWholeOutputAmount 74 | 75 | # exact amount in that is fully spent in one for zero 76 | price = encodePriceSqrt(1, 1) 77 | priceTarget = encodePriceSqrt(1000, 100) 78 | liquidity = expandTo18Decimals(2) 79 | amount = expandTo18Decimals(1) 80 | fee = 600 81 | zeroForOne = False 82 | 83 | sqrtQ, amountIn, amountOut, feeAmount = SwapMath.computeSwapStep( 84 | price, priceTarget, liquidity, amount, fee 85 | ) 86 | 87 | assert amountIn == 999400000000000000 88 | assert feeAmount == 600000000000000 89 | assert amountOut == 666399946655997866 90 | assert amountIn + feeAmount == amount 91 | 92 | priceAfterWholeInputAmountLessFee = SqrtPriceMath.getNextSqrtPriceFromInput( 93 | price, liquidity, amount - feeAmount, zeroForOne 94 | ) 95 | 96 | assert sqrtQ < priceTarget 97 | assert sqrtQ == priceAfterWholeInputAmountLessFee 98 | 99 | # exact amount out that is fully received in one for zero 100 | price = encodePriceSqrt(1, 1) 101 | priceTarget = encodePriceSqrt(10000, 100) 102 | liquidity = expandTo18Decimals(2) 103 | amount = -expandTo18Decimals(1) 104 | fee = 600 105 | zeroForOne = False 106 | 107 | sqrtQ, amountIn, amountOut, feeAmount = SwapMath.computeSwapStep( 108 | price, priceTarget, liquidity, amount, fee 109 | ) 110 | 111 | assert amountIn == 2000000000000000000 112 | assert feeAmount == 1200720432259356 113 | assert amountOut == -amount 114 | 115 | priceAfterWholeOutputAmount = SqrtPriceMath.getNextSqrtPriceFromOutput( 116 | price, liquidity, -amount, zeroForOne 117 | ) 118 | 119 | assert sqrtQ < priceTarget 120 | assert sqrtQ == priceAfterWholeOutputAmount 121 | 122 | # amount out is capped at the desired amount out 123 | sqrtQ, amountIn, amountOut, feeAmount = SwapMath.computeSwapStep( 124 | 417332158212080721273783715441582, 125 | 1452870262520218020823638996, 126 | 159344665391607089467575320103, 127 | -1, 128 | 1, 129 | ) 130 | 131 | assert amountIn == 1 132 | assert feeAmount == 1 133 | assert amountOut == 1 # would be 2 if not capped 134 | assert sqrtQ == 417332158212080721273783715441581 135 | 136 | # target price of 1 uses partial input amount 137 | sqrtQ, amountIn, amountOut, feeAmount = SwapMath.computeSwapStep( 138 | 2, 139 | 1, 140 | 1, 141 | 3915081100057732413702495386755767, 142 | 1, 143 | ) 144 | assert amountIn == 39614081257132168796771975168 145 | assert feeAmount == 39614120871253040049813 146 | assert amountIn + feeAmount <= 3915081100057732413702495386755767 147 | assert amountOut == 0 148 | assert sqrtQ == 1 149 | 150 | # entire input amount taken as fee 151 | sqrtQ, amountIn, amountOut, feeAmount = SwapMath.computeSwapStep( 152 | 2413, 153 | 79887613182836312, 154 | 1985041575832132834610021537970, 155 | 10, 156 | 1872, 157 | ) 158 | assert amountIn == 0 159 | assert feeAmount == 10 160 | assert amountOut == 0 161 | assert sqrtQ == 2413 162 | 163 | # handles intermediate insufficient liquidity in zero for one exact output case 164 | sqrtP = 20282409603651670423947251286016 165 | sqrtPTarget = sqrtP * 11 // 10 166 | liquidity = 1024 167 | # virtual reserves of one are only 4 168 | # https://www.wolframalpha.com/input/?i=1024+%2F+%2820282409603651670423947251286016+%2F+2**96%29 169 | amountRemaining = -4 170 | feePips = 3000 171 | sqrtQ, amountIn, amountOut, feeAmount = SwapMath.computeSwapStep( 172 | sqrtP, sqrtPTarget, liquidity, amountRemaining, feePips 173 | ) 174 | assert amountOut == 0 175 | assert sqrtQ == sqrtPTarget 176 | assert amountIn == 26215 177 | assert feeAmount == 79 178 | 179 | # handles intermediate insufficient liquidity in one for zero exact output case 180 | sqrtP = 20282409603651670423947251286016 181 | sqrtPTarget = sqrtP * 9 // 10 182 | liquidity = 1024 183 | # virtual reserves of zero are only 262144 184 | # https://www.wolframalpha.com/input/?i=1024+*+%2820282409603651670423947251286016+%2F+2**96%29 185 | amountRemaining = -263000 186 | feePips = 3000 187 | sqrtQ, amountIn, amountOut, feeAmount = SwapMath.computeSwapStep( 188 | sqrtP, sqrtPTarget, liquidity, amountRemaining, feePips 189 | ) 190 | assert amountOut == 26214 191 | assert sqrtQ == sqrtPTarget 192 | assert amountIn == 1 193 | assert feeAmount == 1 194 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v3_libraries/tick_math.py: -------------------------------------------------------------------------------- 1 | from ...constants import MAX_UINT160, MAX_UINT256, MIN_UINT160 2 | from ...exceptions import EVMRevertError 3 | from . import yul_operations as yul 4 | 5 | MIN_TICK = -887272 6 | MAX_TICK = -MIN_TICK 7 | MIN_SQRT_RATIO = 4295128739 8 | MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342 9 | 10 | 11 | def getSqrtRatioAtTick(tick: int) -> int: 12 | abs_tick = abs(tick) 13 | if not (0 <= abs_tick <= MAX_TICK): 14 | raise EVMRevertError("T") 15 | 16 | ratio = ( 17 | 0xFFFCB933BD6FAD37AA2D162D1A594001 18 | if (abs_tick & 0x1 != 0) 19 | else 0x100000000000000000000000000000000 20 | ) 21 | 22 | if abs_tick & 0x2 != 0: 23 | ratio = (ratio * 0xFFF97272373D413259A46990580E213A) >> 128 24 | if abs_tick & 0x4 != 0: 25 | ratio = (ratio * 0xFFF2E50F5F656932EF12357CF3C7FDCC) >> 128 26 | if abs_tick & 0x8 != 0: 27 | ratio = (ratio * 0xFFE5CACA7E10E4E61C3624EAA0941CD0) >> 128 28 | if abs_tick & 0x10 != 0: 29 | ratio = (ratio * 0xFFCB9843D60F6159C9DB58835C926644) >> 128 30 | if abs_tick & 0x20 != 0: 31 | ratio = (ratio * 0xFF973B41FA98C081472E6896DFB254C0) >> 128 32 | if abs_tick & 0x40 != 0: 33 | ratio = (ratio * 0xFF2EA16466C96A3843EC78B326B52861) >> 128 34 | if abs_tick & 0x80 != 0: 35 | ratio = (ratio * 0xFE5DEE046A99A2A811C461F1969C3053) >> 128 36 | if abs_tick & 0x100 != 0: 37 | ratio = (ratio * 0xFCBE86C7900A88AEDCFFC83B479AA3A4) >> 128 38 | if abs_tick & 0x200 != 0: 39 | ratio = (ratio * 0xF987A7253AC413176F2B074CF7815E54) >> 128 40 | if abs_tick & 0x400 != 0: 41 | ratio = (ratio * 0xF3392B0822B70005940C7A398E4B70F3) >> 128 42 | if abs_tick & 0x800 != 0: 43 | ratio = (ratio * 0xE7159475A2C29B7443B29C7FA6E889D9) >> 128 44 | if abs_tick & 0x1000 != 0: 45 | ratio = (ratio * 0xD097F3BDFD2022B8845AD8F792AA5825) >> 128 46 | if abs_tick & 0x2000 != 0: 47 | ratio = (ratio * 0xA9F746462D870FDF8A65DC1F90E061E5) >> 128 48 | if abs_tick & 0x4000 != 0: 49 | ratio = (ratio * 0x70D869A156D2A1B890BB3DF62BAF32F7) >> 128 50 | if abs_tick & 0x8000 != 0: 51 | ratio = (ratio * 0x31BE135F97D08FD981231505542FCFA6) >> 128 52 | if abs_tick & 0x10000 != 0: 53 | ratio = (ratio * 0x9AA508B5B7A84E1C677DE54F3E99BC9) >> 128 54 | if abs_tick & 0x20000 != 0: 55 | ratio = (ratio * 0x5D6AF8DEDB81196699C329225EE604) >> 128 56 | if abs_tick & 0x40000 != 0: 57 | ratio = (ratio * 0x2216E584F5FA1EA926041BEDFE98) >> 128 58 | if abs_tick & 0x80000 != 0: 59 | ratio = (ratio * 0x48A170391F7DC42444E8FA2) >> 128 60 | 61 | if tick > 0: 62 | ratio = (MAX_UINT256) // ratio 63 | 64 | # this divides by 1<<32 rounding up to go from a Q128.128 to a Q128.96 65 | # we then downcast because we know the result always fits within 160 bits due to our tick input constraint 66 | # we round up in the division so getTickAtSqrtRatio of the output price is always consistent 67 | return (ratio >> 32) + (0 if (ratio % (1 << 32) == 0) else 1) 68 | 69 | 70 | def getTickAtSqrtRatio(sqrt_price_x96: int) -> int: 71 | if not (MIN_UINT160 <= sqrt_price_x96 <= MAX_UINT160): 72 | raise EVMRevertError("Not a valid uint160") 73 | 74 | # second inequality must be < because the price can never reach the price at the max tick 75 | if not (sqrt_price_x96 >= MIN_SQRT_RATIO and sqrt_price_x96 < MAX_SQRT_RATIO): 76 | raise EVMRevertError("R") 77 | 78 | ratio = sqrt_price_x96 << 32 79 | 80 | r: int = ratio 81 | msb: int = 0 82 | 83 | f = yul.shl(7, yul.gt(r, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)) 84 | msb = yul.or_(msb, f) 85 | r = yul.shr(f, r) 86 | 87 | f = yul.shl(7, yul.gt(r, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)) 88 | msb = yul.or_(msb, f) 89 | r = yul.shr(f, r) 90 | 91 | f = yul.shl(6, yul.gt(r, 0xFFFFFFFFFFFFFFFF)) 92 | msb = yul.or_(msb, f) 93 | r = yul.shr(f, r) 94 | 95 | f = yul.shl(5, yul.gt(r, 0xFFFFFFFF)) 96 | msb = yul.or_(msb, f) 97 | r = yul.shr(f, r) 98 | 99 | f = yul.shl(4, yul.gt(r, 0xFFFF)) 100 | msb = yul.or_(msb, f) 101 | r = yul.shr(f, r) 102 | 103 | f = yul.shl(3, yul.gt(r, 0xFF)) 104 | msb = yul.or_(msb, f) 105 | r = yul.shr(f, r) 106 | 107 | f = yul.shl(2, yul.gt(r, 0xF)) 108 | msb = yul.or_(msb, f) 109 | r = yul.shr(f, r) 110 | 111 | f = yul.shl(1, yul.gt(r, 0x3)) 112 | msb = yul.or_(msb, f) 113 | r = yul.shr(f, r) 114 | 115 | f = yul.gt(r, 0x1) 116 | msb = yul.or_(msb, f) 117 | 118 | if msb >= 128: 119 | r = ratio >> (msb - 127) 120 | else: 121 | r = ratio << (127 - msb) 122 | 123 | log_2 = (int(msb) - 128) << 64 124 | 125 | r = yul.shr(127, yul.mul(r, r)) 126 | f = yul.shr(128, r) 127 | log_2 = yul.or_(log_2, yul.shl(63, f)) 128 | r = yul.shr(f, r) 129 | 130 | r = yul.shr(127, yul.mul(r, r)) 131 | f = yul.shr(128, r) 132 | log_2 = yul.or_(log_2, yul.shl(62, f)) 133 | r = yul.shr(f, r) 134 | 135 | r = yul.shr(127, yul.mul(r, r)) 136 | f = yul.shr(128, r) 137 | log_2 = yul.or_(log_2, yul.shl(61, f)) 138 | r = yul.shr(f, r) 139 | 140 | r = yul.shr(127, yul.mul(r, r)) 141 | f = yul.shr(128, r) 142 | log_2 = yul.or_(log_2, yul.shl(60, f)) 143 | r = yul.shr(f, r) 144 | 145 | r = yul.shr(127, yul.mul(r, r)) 146 | f = yul.shr(128, r) 147 | log_2 = yul.or_(log_2, yul.shl(59, f)) 148 | r = yul.shr(f, r) 149 | 150 | r = yul.shr(127, yul.mul(r, r)) 151 | f = yul.shr(128, r) 152 | log_2 = yul.or_(log_2, yul.shl(58, f)) 153 | r = yul.shr(f, r) 154 | 155 | r = yul.shr(127, yul.mul(r, r)) 156 | f = yul.shr(128, r) 157 | log_2 = yul.or_(log_2, yul.shl(57, f)) 158 | r = yul.shr(f, r) 159 | 160 | r = yul.shr(127, yul.mul(r, r)) 161 | f = yul.shr(128, r) 162 | log_2 = yul.or_(log_2, yul.shl(56, f)) 163 | r = yul.shr(f, r) 164 | 165 | r = yul.shr(127, yul.mul(r, r)) 166 | f = yul.shr(128, r) 167 | log_2 = yul.or_(log_2, yul.shl(55, f)) 168 | r = yul.shr(f, r) 169 | 170 | r = yul.shr(127, yul.mul(r, r)) 171 | f = yul.shr(128, r) 172 | log_2 = yul.or_(log_2, yul.shl(54, f)) 173 | r = yul.shr(f, r) 174 | 175 | r = yul.shr(127, yul.mul(r, r)) 176 | f = yul.shr(128, r) 177 | log_2 = yul.or_(log_2, yul.shl(53, f)) 178 | r = yul.shr(f, r) 179 | 180 | r = yul.shr(127, yul.mul(r, r)) 181 | f = yul.shr(128, r) 182 | log_2 = yul.or_(log_2, yul.shl(52, f)) 183 | r = yul.shr(f, r) 184 | 185 | r = yul.shr(127, yul.mul(r, r)) 186 | f = yul.shr(128, r) 187 | log_2 = yul.or_(log_2, yul.shl(51, f)) 188 | r = yul.shr(f, r) 189 | 190 | r = yul.shr(127, yul.mul(r, r)) 191 | f = yul.shr(128, r) 192 | log_2 = yul.or_(log_2, yul.shl(50, f)) 193 | 194 | log_sqrt10001 = log_2 * 255738958999603826347141 # 128.128 number 195 | 196 | tick_low = (log_sqrt10001 - 3402992956809132418596140100660247210) >> 128 197 | tick_high = (log_sqrt10001 + 291339464771989622907027621153398088495) >> 128 198 | 199 | tick = ( 200 | tick_low 201 | if (tick_low == tick_high) 202 | else (tick_high if getSqrtRatioAtTick(tick_high) <= sqrt_price_x96 else tick_low) 203 | ) 204 | 205 | return tick 206 | -------------------------------------------------------------------------------- /src/degenbot/uniswap/v3_snapshot.py: -------------------------------------------------------------------------------- 1 | # TODO: support unwinding updates for re-org 2 | 3 | 4 | from io import TextIOWrapper 5 | from typing import Any, Dict, List, TextIO, Tuple 6 | 7 | import ujson 8 | from eth_typing import ChecksumAddress 9 | from eth_utils.address import to_checksum_address 10 | from web3 import Web3 11 | from web3._utils.events import get_event_data 12 | from web3._utils.filters import construct_event_filter_params 13 | 14 | from .. import config 15 | from ..logging import logger 16 | from .abi import UNISWAP_V3_POOL_ABI 17 | from .v3_dataclasses import ( 18 | UniswapV3BitmapAtWord, 19 | UniswapV3LiquidityAtTick, 20 | UniswapV3LiquidityEvent, 21 | UniswapV3PoolExternalUpdate, 22 | ) 23 | 24 | 25 | class UniswapV3LiquiditySnapshot: 26 | """ 27 | Retrieve and maintain liquidity positions for Uniswap V3 pools. 28 | """ 29 | 30 | def __init__( 31 | self, 32 | file: TextIO | str, 33 | chain_id: int | None = None, 34 | ): 35 | file_handle: TextIOWrapper 36 | json_liquidity_snapshot: Dict[str, Any] 37 | 38 | match file: 39 | case TextIOWrapper(): 40 | file_handle = file 41 | json_liquidity_snapshot = ujson.load(file) 42 | case str(): 43 | with open(file) as file_handle: 44 | json_liquidity_snapshot = ujson.load(file_handle) 45 | case _: # pragma: no cover 46 | raise ValueError(f"Unrecognized file type {type(file)}") 47 | 48 | self._chain_id = chain_id if chain_id is not None else config.get_web3().eth.chain_id 49 | 50 | self.newest_block = json_liquidity_snapshot.pop("snapshot_block") 51 | 52 | self._liquidity_snapshot: Dict[ChecksumAddress, Dict[str, Any]] = { 53 | to_checksum_address(pool_address): { 54 | "tick_bitmap": { 55 | int(k): UniswapV3BitmapAtWord(**v) 56 | for k, v in pool_liquidity_snapshot["tick_bitmap"].items() 57 | }, 58 | "tick_data": { 59 | int(k): UniswapV3LiquidityAtTick(**v) 60 | for k, v in pool_liquidity_snapshot["tick_data"].items() 61 | }, 62 | } 63 | for pool_address, pool_liquidity_snapshot in json_liquidity_snapshot.items() 64 | } 65 | 66 | logger.info( 67 | f"Loaded LP snapshot: {len(json_liquidity_snapshot)} pools @ block {self.newest_block}" 68 | ) 69 | 70 | self._liquidity_events: Dict[ChecksumAddress, List[UniswapV3LiquidityEvent]] = dict() 71 | 72 | def _add_pool_if_missing(self, pool_address: ChecksumAddress) -> None: 73 | try: 74 | self._liquidity_events[pool_address] 75 | except KeyError: 76 | self._liquidity_events[pool_address] = [] 77 | 78 | try: 79 | self._liquidity_snapshot[pool_address] 80 | except KeyError: 81 | self._liquidity_snapshot[pool_address] = {} 82 | 83 | def fetch_new_liquidity_events( 84 | self, 85 | to_block: int, 86 | span: int = 1000, 87 | ) -> None: 88 | def _process_log() -> Tuple[ChecksumAddress, UniswapV3LiquidityEvent]: 89 | decoded_event = get_event_data(config.get_web3().codec, event_abi, log) 90 | 91 | pool_address = to_checksum_address(decoded_event["address"]) 92 | tx_index = decoded_event["transactionIndex"] 93 | liquidity_block = decoded_event["blockNumber"] 94 | liquidity = decoded_event["args"]["amount"] * ( 95 | -1 if decoded_event["event"] == "Burn" else 1 96 | ) 97 | tick_lower = decoded_event["args"]["tickLower"] 98 | tick_upper = decoded_event["args"]["tickUpper"] 99 | 100 | return pool_address, UniswapV3LiquidityEvent( 101 | block_number=liquidity_block, 102 | liquidity=liquidity, 103 | tick_lower=tick_lower, 104 | tick_upper=tick_upper, 105 | tx_index=tx_index, 106 | ) 107 | 108 | logger.info(f"Updating snapshot from block {self.newest_block} to {to_block}") 109 | 110 | v3pool = Web3().eth.contract(abi=UNISWAP_V3_POOL_ABI) 111 | 112 | for event in [v3pool.events.Mint, v3pool.events.Burn]: 113 | logger.info(f"Processing {event.event_name} events") 114 | event_abi = event._get_event_abi() 115 | start_block = self.newest_block + 1 116 | 117 | while True: 118 | end_block = min(to_block, start_block + span - 1) 119 | 120 | _, event_filter_params = construct_event_filter_params( 121 | event_abi=event_abi, 122 | abi_codec=config.get_web3().codec, 123 | fromBlock=start_block, 124 | toBlock=end_block, 125 | ) 126 | 127 | event_logs = config.get_web3().eth.get_logs(event_filter_params) 128 | 129 | for log in event_logs: 130 | pool_address, liquidity_event = _process_log() 131 | 132 | if liquidity_event.liquidity == 0: # pragma: no cover 133 | continue 134 | 135 | self._add_pool_if_missing(pool_address) 136 | self._liquidity_events[pool_address].append(liquidity_event) 137 | 138 | if end_block == to_block: 139 | break 140 | else: 141 | start_block = end_block + 1 142 | 143 | logger.info(f"Updated snapshot to block {to_block}") 144 | self.newest_block = to_block 145 | 146 | def get_new_liquidity_updates(self, pool_address: str) -> List[UniswapV3PoolExternalUpdate]: 147 | pool_address = to_checksum_address(pool_address) 148 | pool_updates = self._liquidity_events.get(pool_address, list()) 149 | self._liquidity_events[pool_address] = list() 150 | 151 | # The V3LiquidityPool helper will reject liquidity events associated with a past block, so 152 | # they must be applied in chronological order 153 | sorted_events = sorted( 154 | pool_updates, 155 | key=lambda event: (event.block_number, event.tx_index), 156 | ) 157 | 158 | return [ 159 | UniswapV3PoolExternalUpdate( 160 | block_number=event.block_number, 161 | liquidity_change=( 162 | event.liquidity, 163 | event.tick_lower, 164 | event.tick_upper, 165 | ), 166 | ) 167 | for event in sorted_events 168 | ] 169 | 170 | def get_tick_bitmap(self, pool: ChecksumAddress | str) -> Dict[int, UniswapV3BitmapAtWord]: 171 | pool_address = to_checksum_address(pool) 172 | 173 | try: 174 | tick_bitmap: Dict[int, UniswapV3BitmapAtWord] = self._liquidity_snapshot[pool_address][ 175 | "tick_bitmap" 176 | ] 177 | return tick_bitmap 178 | except KeyError: 179 | return dict() 180 | 181 | def get_tick_data(self, pool: ChecksumAddress | str) -> Dict[int, UniswapV3LiquidityAtTick]: 182 | pool_address = to_checksum_address(pool) 183 | 184 | try: 185 | tick_data: Dict[int, UniswapV3LiquidityAtTick] = self._liquidity_snapshot[pool_address][ 186 | "tick_data" 187 | ] 188 | return tick_data 189 | except KeyError: 190 | return {} 191 | 192 | def update_snapshot( 193 | self, 194 | pool: ChecksumAddress | str, 195 | tick_data: Dict[int, UniswapV3LiquidityAtTick], 196 | tick_bitmap: Dict[int, UniswapV3BitmapAtWord], 197 | ) -> None: 198 | pool_address = to_checksum_address(pool) 199 | 200 | self._add_pool_if_missing(pool_address) 201 | self._liquidity_snapshot[pool_address].update( 202 | { 203 | "tick_bitmap": tick_bitmap, 204 | } 205 | ) 206 | self._liquidity_snapshot[pool_address].update( 207 | { 208 | "tick_data": tick_data, 209 | } 210 | ) 211 | -------------------------------------------------------------------------------- /tests/test_builder_endpoint.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import eth_account 3 | import pytest 4 | from degenbot.builder_endpoint import BuilderEndpoint 5 | from degenbot.fork.anvil_fork import AnvilFork 6 | from eth_account.signers.local import LocalAccount 7 | 8 | BEAVERBUILD_URL = "https://rpc.beaverbuild.org" 9 | BUILDER0X69_URL = "https://builder0x69.io" 10 | FLASHBOTS_URL = "https://relay.flashbots.net" 11 | RSYNCBUILDER_URL = "https://rsync-builder.xyz" 12 | TITANBUILDER_URL = "https://rpc.titanbuilder.xyz" 13 | 14 | 15 | BUILDER_FIXTURES = [ 16 | "beaverbuild", 17 | "builder0x69", 18 | "flashbots", 19 | "rsyncbuilder", 20 | "titanbuilder", 21 | ] 22 | 23 | TEST_BUNDLE_HASH: str 24 | TEST_BUNDLE_BLOCK: int 25 | 26 | # Taken from https://privatekeys.pw/keys/ethereum/random 27 | SIGNER_KEY = "52661f05c0512d64e2dc681900f45996e9946856ec352b7a2950203b150dbd28" 28 | 29 | 30 | @pytest.fixture() 31 | def beaverbuild() -> BuilderEndpoint: 32 | return BuilderEndpoint( 33 | url=BEAVERBUILD_URL, 34 | endpoints=["eth_sendBundle"], 35 | ) 36 | 37 | 38 | @pytest.fixture() 39 | def builder0x69() -> BuilderEndpoint: 40 | return BuilderEndpoint( 41 | url=BUILDER0X69_URL, 42 | endpoints=["eth_sendBundle"], 43 | # ref: https://docs.builder0x69.io/ 44 | authentication_header_label="X-Flashbots-Signature", 45 | ) 46 | 47 | 48 | @pytest.fixture() 49 | def flashbots() -> BuilderEndpoint: 50 | return BuilderEndpoint( 51 | url=FLASHBOTS_URL, 52 | endpoints=[ 53 | "eth_callBundle", 54 | "eth_sendBundle", 55 | "flashbots_getUserStatsV2", 56 | "flashbots_getBundleStatsV2", 57 | ], 58 | authentication_header_label="X-Flashbots-Signature", 59 | ) 60 | 61 | 62 | @pytest.fixture() 63 | def rsyncbuilder() -> BuilderEndpoint: 64 | return BuilderEndpoint( 65 | url=RSYNCBUILDER_URL, 66 | endpoints=[ 67 | "eth_cancelBundle", 68 | "eth_sendBundle", 69 | "eth_sendPrivateRawTransaction", 70 | ], 71 | authentication_header_label="X-Flashbots-Signature", 72 | ) 73 | 74 | 75 | @pytest.fixture() 76 | def titanbuilder() -> BuilderEndpoint: 77 | return BuilderEndpoint( 78 | url=TITANBUILDER_URL, 79 | endpoints=["eth_sendBundle"], 80 | # ref: https://docs.titanbuilder.xyz/authentication 81 | authentication_header_label="X-Flashbots-Signature", 82 | ) 83 | 84 | 85 | @pytest.mark.parametrize( 86 | "builder_name", 87 | BUILDER_FIXTURES, 88 | ) 89 | def test_create_builders(builder_name: str, request: pytest.FixtureRequest): 90 | builder = request.getfixturevalue(builder_name) 91 | assert isinstance(builder, BuilderEndpoint) 92 | 93 | 94 | async def test_bad_url(): 95 | with pytest.raises(ValueError): 96 | BuilderEndpoint(url="ws://www.google.com", endpoints=["eth_sendBundle"]) 97 | 98 | 99 | async def test_blank_eth_send_bundle( 100 | beaverbuild: BuilderEndpoint, 101 | fork_mainnet: AnvilFork, 102 | ): 103 | current_block = fork_mainnet.w3.eth.block_number 104 | response = await beaverbuild.send_eth_bundle( 105 | bundle=[], 106 | block_number=current_block + 1, 107 | ) 108 | assert isinstance(response, dict) 109 | assert "bundleHash" in response 110 | 111 | 112 | async def test_blank_eth_send_bundle_with_session( 113 | beaverbuild: BuilderEndpoint, 114 | fork_mainnet: AnvilFork, 115 | ): 116 | async with aiohttp.ClientSession(raise_for_status=True) as session: 117 | current_block = fork_mainnet.w3.eth.block_number 118 | response = await beaverbuild.send_eth_bundle( 119 | bundle=[], block_number=current_block + 1, http_session=session 120 | ) 121 | assert isinstance(response, dict) 122 | assert "bundleHash" in response 123 | 124 | 125 | async def test_eth_call_bundle( 126 | flashbots: BuilderEndpoint, 127 | fork_mainnet: AnvilFork, 128 | ): 129 | current_block = fork_mainnet.w3.eth.block_number 130 | current_base_fee = fork_mainnet.w3.eth.get_block("latest")["baseFeePerGas"] 131 | current_block_timestamp = fork_mainnet.w3.eth.get_block("latest")["timestamp"] 132 | 133 | signer: LocalAccount = eth_account.Account.from_key(SIGNER_KEY) 134 | SIGNER_ADDRESS = signer.address 135 | 136 | transaction_1 = { 137 | "chainId": 1, 138 | "data": b"", 139 | "from": SIGNER_ADDRESS, 140 | "to": SIGNER_ADDRESS, 141 | "value": 1, 142 | "nonce": fork_mainnet.w3.eth.get_transaction_count(SIGNER_ADDRESS), 143 | "gas": 50_000, 144 | "maxFeePerGas": int(1.5 * current_base_fee), 145 | "maxPriorityFeePerGas": 0, 146 | } 147 | transaction_2 = { 148 | "chainId": 1, 149 | "data": b"", 150 | "from": SIGNER_ADDRESS, 151 | "to": SIGNER_ADDRESS, 152 | "value": 1, 153 | "nonce": fork_mainnet.w3.eth.get_transaction_count(SIGNER_ADDRESS) + 1, 154 | "gas": 50_000, 155 | "maxFeePerGas": int(1.5 * current_base_fee), 156 | "maxPriorityFeePerGas": 0, 157 | } 158 | signed_tx_1 = fork_mainnet.w3.eth.account.sign_transaction( 159 | transaction_1, SIGNER_KEY 160 | ).rawTransaction 161 | signed_tx_2 = fork_mainnet.w3.eth.account.sign_transaction( 162 | transaction_2, SIGNER_KEY 163 | ).rawTransaction 164 | 165 | response = await flashbots.call_eth_bundle( 166 | bundle=[signed_tx_1, signed_tx_2], 167 | block_number=current_block + 1, 168 | state_block=current_block, 169 | signer_key=SIGNER_KEY, 170 | ) 171 | assert isinstance(response, dict) 172 | 173 | # Test with "latest" state block alias 174 | response = await flashbots.call_eth_bundle( 175 | bundle=[signed_tx_1, signed_tx_2], 176 | block_number=current_block + 1, 177 | state_block="latest", 178 | signer_key=SIGNER_KEY, 179 | ) 180 | assert isinstance(response, dict) 181 | 182 | # Test with timestamp 183 | response = await flashbots.call_eth_bundle( 184 | bundle=[signed_tx_1, signed_tx_2], 185 | block_number=current_block + 1, 186 | block_timestamp=current_block_timestamp + 12, 187 | state_block=current_block, 188 | signer_key=SIGNER_KEY, 189 | ) 190 | assert isinstance(response, dict) 191 | 192 | 193 | async def test_eth_send_bundle( 194 | flashbots: BuilderEndpoint, 195 | fork_mainnet: AnvilFork, 196 | ): 197 | current_block = fork_mainnet.w3.eth.block_number 198 | current_base_fee = fork_mainnet.w3.eth.get_block("latest")["baseFeePerGas"] 199 | 200 | signer: LocalAccount = eth_account.Account.from_key(SIGNER_KEY) 201 | SIGNER_ADDRESS = signer.address 202 | 203 | transaction_1 = { 204 | "chainId": 1, 205 | "data": b"", 206 | "from": SIGNER_ADDRESS, 207 | "to": SIGNER_ADDRESS, 208 | "value": 1, 209 | "nonce": fork_mainnet.w3.eth.get_transaction_count(SIGNER_ADDRESS), 210 | "gas": 50_000, 211 | "maxFeePerGas": int(1.5 * current_base_fee), 212 | "maxPriorityFeePerGas": 0, 213 | } 214 | transaction_2 = { 215 | "chainId": 1, 216 | "data": b"", 217 | "from": SIGNER_ADDRESS, 218 | "to": SIGNER_ADDRESS, 219 | "value": 1, 220 | "nonce": fork_mainnet.w3.eth.get_transaction_count(SIGNER_ADDRESS) + 1, 221 | "gas": 50_000, 222 | "maxFeePerGas": int(1.5 * current_base_fee), 223 | "maxPriorityFeePerGas": 0, 224 | } 225 | signed_tx_1 = fork_mainnet.w3.eth.account.sign_transaction( 226 | transaction_1, SIGNER_KEY 227 | ).rawTransaction 228 | signed_tx_2 = fork_mainnet.w3.eth.account.sign_transaction( 229 | transaction_2, SIGNER_KEY 230 | ).rawTransaction 231 | 232 | response = await flashbots.send_eth_bundle( 233 | bundle=[signed_tx_1, signed_tx_2], 234 | block_number=current_block + 1, 235 | signer_key=SIGNER_KEY, 236 | ) 237 | assert isinstance(response, dict) 238 | 239 | global TEST_BUNDLE_HASH 240 | global TEST_BUNDLE_BLOCK 241 | TEST_BUNDLE_HASH = response["bundleHash"] 242 | TEST_BUNDLE_BLOCK = current_block + 1 243 | 244 | 245 | async def test_get_user_stats( 246 | flashbots: BuilderEndpoint, 247 | fork_mainnet: AnvilFork, 248 | ): 249 | current_block = fork_mainnet.w3.eth.block_number 250 | await flashbots.get_user_stats( 251 | signer_key=SIGNER_KEY, 252 | block_number=current_block, 253 | ) 254 | 255 | 256 | async def test_get_bundle_stats(flashbots: BuilderEndpoint): 257 | await flashbots.get_bundle_stats( 258 | bundle_hash=TEST_BUNDLE_HASH, 259 | block_number=TEST_BUNDLE_BLOCK, 260 | signer_key=SIGNER_KEY, 261 | ) 262 | -------------------------------------------------------------------------------- /tests/uniswap/test_uniswap_managers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from degenbot.config import set_web3 3 | from degenbot.exceptions import ManagerError, PoolNotAssociated 4 | from degenbot.fork.anvil_fork import AnvilFork 5 | from degenbot.registry.all_pools import AllPools 6 | from degenbot.uniswap.managers import UniswapV2LiquidityPoolManager, UniswapV3LiquidityPoolManager 7 | from degenbot.uniswap.v2_functions import get_v2_pools_from_token_path 8 | from eth_utils.address import to_checksum_address 9 | from web3 import Web3 10 | 11 | UNISWAP_V2_FACTORY_ADDRESS = to_checksum_address("0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f") 12 | UNISWAP_V3_FACTORY_ADDRESS = to_checksum_address("0x1F98431c8aD98523631AE4a59f267346ea31F984") 13 | 14 | SUSHISWAP_V2_FACTORY_ADDRESS = to_checksum_address("0xC0AEe478e3658e2610c5F7A4A2E1777cE9e4f2Ac") 15 | 16 | WETH_ADDRESS = to_checksum_address("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") 17 | WBTC_ADDRESS = to_checksum_address("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599") 18 | 19 | SUSHISWAPV2_WETH_WBTC_ADDRESS = to_checksum_address("0xceff51756c56ceffca006cd410b03ffc46dd3a58") 20 | UNISWAPV2_WETH_WBTC_ADDRESS = to_checksum_address("0xBb2b8038a1640196FbE3e38816F3e67Cba72D940") 21 | UNISWAPV3_WETH_WBTC_ADDRESS = to_checksum_address("0xCBCdF9626bC03E24f779434178A73a0B4bad62eD") 22 | 23 | 24 | def test_create_managers(ethereum_full_node_web3: Web3): 25 | set_web3(ethereum_full_node_web3) 26 | 27 | uniswap_v2_pool_manager = UniswapV2LiquidityPoolManager( 28 | factory_address=UNISWAP_V2_FACTORY_ADDRESS 29 | ) 30 | sushiswap_v2_pool_manager = UniswapV2LiquidityPoolManager( 31 | factory_address=SUSHISWAP_V2_FACTORY_ADDRESS 32 | ) 33 | 34 | assert uniswap_v2_pool_manager._factory_address == UNISWAP_V2_FACTORY_ADDRESS 35 | assert sushiswap_v2_pool_manager._factory_address == SUSHISWAP_V2_FACTORY_ADDRESS 36 | 37 | # Create a pool manager with an invalid address 38 | with pytest.raises( 39 | ManagerError, 40 | match=f"Pool manager could not be initialized from unknown factory address {WETH_ADDRESS}. Add the factory address and pool init hash with `add_factory`, followed by `add_pool_init_hash`", 41 | ): 42 | UniswapV2LiquidityPoolManager(factory_address=WETH_ADDRESS) 43 | 44 | # Ensure each pool manager has a unique state 45 | assert uniswap_v2_pool_manager.__dict__ is not sushiswap_v2_pool_manager.__dict__ 46 | 47 | assert ( 48 | uniswap_v2_pool_manager._untracked_pools is not sushiswap_v2_pool_manager._untracked_pools 49 | ) 50 | 51 | uniswap_v3_pool_manager = UniswapV3LiquidityPoolManager( 52 | factory_address=UNISWAP_V3_FACTORY_ADDRESS 53 | ) 54 | 55 | # Get known pairs 56 | uniswap_v2_lp = uniswap_v2_pool_manager.get_pool( 57 | token_addresses=( 58 | WETH_ADDRESS, 59 | WBTC_ADDRESS, 60 | ) 61 | ) 62 | sushiswap_v2_lp = sushiswap_v2_pool_manager.get_pool( 63 | token_addresses=( 64 | WETH_ADDRESS, 65 | WBTC_ADDRESS, 66 | ) 67 | ) 68 | uniswap_v3_lp = uniswap_v3_pool_manager.get_pool( 69 | token_addresses=( 70 | WETH_ADDRESS, 71 | WBTC_ADDRESS, 72 | ), 73 | pool_fee=3000, 74 | ) 75 | 76 | assert uniswap_v2_lp.address == UNISWAPV2_WETH_WBTC_ADDRESS 77 | assert sushiswap_v2_lp.address == SUSHISWAPV2_WETH_WBTC_ADDRESS 78 | assert uniswap_v3_lp.address == UNISWAPV3_WETH_WBTC_ADDRESS 79 | 80 | # Create one-off pool managers and verify they return the same object 81 | assert ( 82 | UniswapV2LiquidityPoolManager(factory_address=UNISWAP_V2_FACTORY_ADDRESS).get_pool( 83 | token_addresses=( 84 | WETH_ADDRESS, 85 | WBTC_ADDRESS, 86 | ) 87 | ) 88 | is uniswap_v2_lp 89 | ) 90 | assert ( 91 | UniswapV2LiquidityPoolManager(factory_address=SUSHISWAP_V2_FACTORY_ADDRESS).get_pool( 92 | token_addresses=( 93 | WETH_ADDRESS, 94 | WBTC_ADDRESS, 95 | ) 96 | ) 97 | is sushiswap_v2_lp 98 | ) 99 | assert ( 100 | UniswapV3LiquidityPoolManager(factory_address=UNISWAP_V3_FACTORY_ADDRESS).get_pool( 101 | token_addresses=( 102 | WETH_ADDRESS, 103 | WBTC_ADDRESS, 104 | ), 105 | pool_fee=3000, 106 | ) 107 | is uniswap_v3_lp 108 | ) 109 | 110 | # Calling get_pool at the wrong pool manager should raise an exception 111 | with pytest.raises( 112 | ManagerError, match=f"Pool {uniswap_v2_lp.address} is not associated with this DEX" 113 | ): 114 | UniswapV2LiquidityPoolManager(factory_address=SUSHISWAP_V2_FACTORY_ADDRESS).get_pool( 115 | pool_address=uniswap_v2_lp.address 116 | ) 117 | assert uniswap_v2_lp.address in sushiswap_v2_pool_manager._untracked_pools 118 | assert sushiswap_v2_lp.address not in sushiswap_v2_pool_manager._untracked_pools 119 | with pytest.raises(PoolNotAssociated): 120 | UniswapV2LiquidityPoolManager(factory_address=SUSHISWAP_V2_FACTORY_ADDRESS).get_pool( 121 | pool_address=uniswap_v2_lp.address 122 | ) 123 | 124 | with pytest.raises( 125 | ManagerError, match=f"Pool {sushiswap_v2_lp.address} is not associated with this DEX" 126 | ): 127 | UniswapV2LiquidityPoolManager(factory_address=UNISWAP_V2_FACTORY_ADDRESS).get_pool( 128 | pool_address=sushiswap_v2_lp.address 129 | ) 130 | with pytest.raises(PoolNotAssociated): 131 | UniswapV2LiquidityPoolManager(factory_address=UNISWAP_V2_FACTORY_ADDRESS).get_pool( 132 | pool_address=sushiswap_v2_lp.address 133 | ) 134 | assert sushiswap_v2_lp.address in uniswap_v2_pool_manager._untracked_pools 135 | assert uniswap_v2_lp.address not in uniswap_v2_pool_manager._untracked_pools 136 | 137 | 138 | def test_pool_remove_and_recreate(ethereum_full_node_web3: Web3): 139 | set_web3(ethereum_full_node_web3) 140 | 141 | uniswap_v2_pool_manager = UniswapV2LiquidityPoolManager( 142 | factory_address=UNISWAP_V2_FACTORY_ADDRESS 143 | ) 144 | 145 | v2_weth_wbtc_lp = uniswap_v2_pool_manager.get_pool( 146 | token_addresses=( 147 | WETH_ADDRESS, 148 | WBTC_ADDRESS, 149 | ) 150 | ) 151 | 152 | # Redundant but provides test coverage of the __setitem__ method warning if a pool is recreated 153 | AllPools(chain_id=1)[v2_weth_wbtc_lp.address] = v2_weth_wbtc_lp 154 | 155 | # Remove the pool from the manager 156 | del uniswap_v2_pool_manager[v2_weth_wbtc_lp] 157 | 158 | new_v2_weth_wbtc_lp = uniswap_v2_pool_manager.get_pool( 159 | token_addresses=( 160 | WETH_ADDRESS, 161 | WBTC_ADDRESS, 162 | ) 163 | ) 164 | 165 | # The pool manager should have found the original pool in AllPools and re-used it 166 | assert v2_weth_wbtc_lp is new_v2_weth_wbtc_lp 167 | 168 | # Remove from the manager and the AllPools tracker 169 | del uniswap_v2_pool_manager[new_v2_weth_wbtc_lp] 170 | del AllPools(chain_id=1)[new_v2_weth_wbtc_lp] 171 | 172 | # This should be a completely new pool object 173 | super_new_v2_weth_wbtc_lp = uniswap_v2_pool_manager.get_pool( 174 | token_addresses=( 175 | WETH_ADDRESS, 176 | WBTC_ADDRESS, 177 | ) 178 | ) 179 | assert super_new_v2_weth_wbtc_lp is not new_v2_weth_wbtc_lp 180 | assert super_new_v2_weth_wbtc_lp is not v2_weth_wbtc_lp 181 | 182 | assert AllPools(chain_id=1).get(v2_weth_wbtc_lp.address) is super_new_v2_weth_wbtc_lp 183 | len(AllPools(chain_id=1)) 184 | AllPools(chain_id=1)[super_new_v2_weth_wbtc_lp.address] 185 | del AllPools(chain_id=1)[super_new_v2_weth_wbtc_lp.address] 186 | 187 | 188 | def test_pools_from_token_path(ethereum_full_node_web3: Web3) -> None: 189 | set_web3(ethereum_full_node_web3) 190 | 191 | uniswap_v2_pool_manager = UniswapV2LiquidityPoolManager( 192 | factory_address=UNISWAP_V2_FACTORY_ADDRESS 193 | ) 194 | 195 | assert get_v2_pools_from_token_path( 196 | tx_path=[WBTC_ADDRESS, WETH_ADDRESS], 197 | pool_manager=uniswap_v2_pool_manager, 198 | ) == [ 199 | uniswap_v2_pool_manager.get_pool(token_addresses=(WBTC_ADDRESS, WETH_ADDRESS)), 200 | ] 201 | 202 | 203 | def test_same_block(fork_mainnet_archive: AnvilFork): 204 | _BLOCK = 18493777 205 | fork_mainnet_archive.reset(block_number=_BLOCK) 206 | set_web3(fork_mainnet_archive.w3) 207 | 208 | uniswap_v2_pool_manager = UniswapV2LiquidityPoolManager( 209 | factory_address=UNISWAP_V2_FACTORY_ADDRESS 210 | ) 211 | 212 | v2_heyjoe_weth_lp = uniswap_v2_pool_manager.get_pool( 213 | pool_address="0xC928CF054fE73CaB56d753BA4b508da0F82FABFD", 214 | state_block=_BLOCK, 215 | ) 216 | 217 | del uniswap_v2_pool_manager[v2_heyjoe_weth_lp] 218 | del AllPools(chain_id=1)[v2_heyjoe_weth_lp] 219 | 220 | new_v2_heyjoe_weth_lp = uniswap_v2_pool_manager.get_pool( 221 | pool_address="0xC928CF054fE73CaB56d753BA4b508da0F82FABFD", 222 | state_block=_BLOCK, 223 | ) 224 | 225 | assert v2_heyjoe_weth_lp is not new_v2_heyjoe_weth_lp 226 | -------------------------------------------------------------------------------- /tests/fork/test_anvil_fork.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | import pytest 4 | import ujson 5 | import web3.middleware 6 | from degenbot.config import set_web3 7 | from degenbot.constants import MAX_UINT256, MIN_UINT256 8 | from degenbot.fork.anvil_fork import AnvilFork 9 | from eth_utils.address import to_checksum_address 10 | from hexbytes import HexBytes 11 | from web3.providers.ipc import IPCProvider 12 | from web3.types import Wei 13 | 14 | from ..conftest import ETHEREUM_ARCHIVE_NODE_HTTP_URI, ETHEREUM_FULL_NODE_HTTP_URI 15 | 16 | VITALIK_ADDRESS = to_checksum_address("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045") 17 | WETH_ADDRESS = to_checksum_address("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") 18 | 19 | 20 | def test_anvil_forks(): 21 | # Basic constructor 22 | AnvilFork(fork_url=ETHEREUM_FULL_NODE_HTTP_URI) 23 | 24 | # Test optional arguments 25 | AnvilFork(fork_url=ETHEREUM_ARCHIVE_NODE_HTTP_URI, fork_block=18_000_000) 26 | AnvilFork(fork_url=ETHEREUM_FULL_NODE_HTTP_URI, chain_id=1) 27 | AnvilFork(fork_url=ETHEREUM_FULL_NODE_HTTP_URI, base_fee=10 * 10**9) 28 | 29 | 30 | def test_rpc_methods(fork_mainnet_archive: AnvilFork): 31 | with pytest.raises(ValueError, match="Fee outside valid range"): 32 | fork_mainnet_archive.set_next_base_fee(-1) 33 | with pytest.raises(ValueError, match="Fee outside valid range"): 34 | fork_mainnet_archive.set_next_base_fee(MAX_UINT256 + 1) 35 | fork_mainnet_archive.set_next_base_fee(11 * 10**9) 36 | 37 | # Set several snapshot IDs and return to them 38 | snapshot_ids = [] 39 | for _ in range(10): 40 | snapshot_ids.append(fork_mainnet_archive.set_snapshot()) 41 | for id in snapshot_ids: 42 | assert fork_mainnet_archive.return_to_snapshot(id) is True 43 | # No snapshot ID with this value 44 | assert fork_mainnet_archive.return_to_snapshot(100) is False 45 | 46 | # Negative IDs are not allowed 47 | with pytest.raises(ValueError, match="ID cannot be negative"): 48 | fork_mainnet_archive.return_to_snapshot(-1) 49 | 50 | # Generate a 1 wei WETH deposit transaction from Vitalik.eth 51 | weth_contract = fork_mainnet_archive.w3.eth.contract( 52 | address=WETH_ADDRESS, 53 | abi=ujson.loads( 54 | """ 55 | [{"constant":true,"inputs":[],"name":"name","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"guy","type":"address"},{"name":"wad","type":"uint256"}],"name":"approve","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"totalSupply","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"src","type":"address"},{"name":"dst","type":"address"},{"name":"wad","type":"uint256"}],"name":"transferFrom","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"wad","type":"uint256"}],"name":"withdraw","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"decimals","outputs":[{"name":"","type":"uint8"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"symbol","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"dst","type":"address"},{"name":"wad","type":"uint256"}],"name":"transfer","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[],"name":"deposit","outputs":[],"payable":true,"stateMutability":"payable","type":"function"},{"constant":true,"inputs":[{"name":"","type":"address"},{"name":"","type":"address"}],"name":"allowance","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"payable":true,"stateMutability":"payable","type":"fallback"},{"anonymous":false,"inputs":[{"indexed":true,"name":"src","type":"address"},{"indexed":true,"name":"guy","type":"address"},{"indexed":false,"name":"wad","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"src","type":"address"},{"indexed":true,"name":"dst","type":"address"},{"indexed":false,"name":"wad","type":"uint256"}],"name":"Transfer","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"dst","type":"address"},{"indexed":false,"name":"wad","type":"uint256"}],"name":"Deposit","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"src","type":"address"},{"indexed":false,"name":"wad","type":"uint256"}],"name":"Withdrawal","type":"event"}] 56 | """ 57 | ), 58 | ) 59 | deposit_transaction = weth_contract.functions.deposit().build_transaction( 60 | { 61 | "from": VITALIK_ADDRESS, 62 | "value": Wei(1), 63 | }, 64 | ) 65 | access_list = fork_mainnet_archive.create_access_list( 66 | transaction=deposit_transaction, # type: ignore[arg-type] 67 | ) 68 | assert isinstance(access_list, list) 69 | 70 | fork_mainnet_archive.reset(block_number=18_500_000) 71 | with pytest.raises(Exception): 72 | fork_mainnet_archive.reset( 73 | fork_url="http://google.com", # <--- Bad RPC URI 74 | ) 75 | 76 | # switch between two different endpoints 77 | for endpoint in (ETHEREUM_ARCHIVE_NODE_HTTP_URI, ETHEREUM_FULL_NODE_HTTP_URI): 78 | fork_mainnet_archive.reset(fork_url=endpoint) 79 | 80 | for balance in [MIN_UINT256, MAX_UINT256]: 81 | fork_mainnet_archive.set_balance(VITALIK_ADDRESS, balance) 82 | assert fork_mainnet_archive.w3.eth.get_balance(VITALIK_ADDRESS) == balance 83 | 84 | # Balances outside of uint256 should be rejected 85 | with pytest.raises(ValueError): 86 | fork_mainnet_archive.set_balance(VITALIK_ADDRESS, MIN_UINT256 - 1) 87 | with pytest.raises(ValueError): 88 | fork_mainnet_archive.set_balance(VITALIK_ADDRESS, MAX_UINT256 + 1) 89 | 90 | FAKE_COINBASE = to_checksum_address("0x0420042004200420042004200420042004200420") 91 | fork_mainnet_archive.set_coinbase(FAKE_COINBASE) 92 | # @dev the eth_coinbase method fails when called on Anvil, 93 | # so check by mining a block and comparing the miner address 94 | 95 | fork_mainnet_archive.mine() 96 | block = fork_mainnet_archive.w3.eth.get_block("latest") 97 | assert block["miner"] == FAKE_COINBASE 98 | 99 | 100 | def test_mine_and_reset(fork_mainnet_archive: AnvilFork): 101 | starting_block = fork_mainnet_archive.w3.eth.get_block_number() 102 | fork_mainnet_archive.mine() 103 | fork_mainnet_archive.mine() 104 | fork_mainnet_archive.mine() 105 | assert fork_mainnet_archive.w3.eth.get_block_number() == starting_block + 3 106 | fork_mainnet_archive.reset(block_number=starting_block) 107 | assert fork_mainnet_archive.w3.eth.get_block_number() == starting_block 108 | 109 | 110 | def test_set_next_block_base_fee(fork_mainnet_archive: AnvilFork): 111 | BASE_FEE_OVERRIDE = 69 * 10**9 112 | 113 | fork_mainnet_archive.set_next_base_fee(BASE_FEE_OVERRIDE) 114 | fork_mainnet_archive.mine() 115 | assert fork_mainnet_archive.w3.eth.get_block("latest")["baseFeePerGas"] == BASE_FEE_OVERRIDE 116 | 117 | 118 | def test_reset_and_set_next_block_base_fee(fork_mainnet_archive: AnvilFork): 119 | BASE_FEE_OVERRIDE = 69 * 10**9 120 | 121 | starting_block = fork_mainnet_archive.w3.eth.get_block_number() 122 | fork_mainnet_archive.reset(block_number=starting_block - 10, base_fee=BASE_FEE_OVERRIDE) 123 | fork_mainnet_archive.mine() 124 | assert fork_mainnet_archive.w3.eth.get_block_number() == starting_block - 9 125 | assert ( 126 | fork_mainnet_archive.w3.eth.get_block(starting_block - 9)["baseFeePerGas"] 127 | == BASE_FEE_OVERRIDE 128 | ) 129 | 130 | 131 | def test_ipc_kwargs(): 132 | fork = AnvilFork( 133 | fork_url=ETHEREUM_FULL_NODE_HTTP_URI, 134 | ipc_provider_kwargs=dict(timeout=None), 135 | ) 136 | if TYPE_CHECKING: 137 | assert isinstance(fork.w3.provider, IPCProvider) 138 | assert fork.w3.provider.timeout is None 139 | 140 | 141 | def test_balance_overrides_in_constructor(): 142 | FAKE_BALANCE = 100 * 10**18 143 | fork = AnvilFork( 144 | fork_url=ETHEREUM_FULL_NODE_HTTP_URI, 145 | balance_overrides=[ 146 | (VITALIK_ADDRESS, FAKE_BALANCE), 147 | ], 148 | ) 149 | assert fork.w3.eth.get_balance(VITALIK_ADDRESS) == FAKE_BALANCE 150 | 151 | 152 | def test_bytecode_overrides_in_constructor(): 153 | FAKE_ADDRESS = to_checksum_address("0x6969696969696969696969696969696969696969") 154 | FAKE_BYTECODE = HexBytes("0x0420") 155 | 156 | fork = AnvilFork( 157 | fork_url=ETHEREUM_FULL_NODE_HTTP_URI, bytecode_overrides=[(FAKE_ADDRESS, FAKE_BYTECODE)] 158 | ) 159 | assert fork.w3.eth.get_code(FAKE_ADDRESS) == FAKE_BYTECODE 160 | 161 | 162 | def test_coinbase_override_in_constructor(): 163 | FAKE_COINBASE = to_checksum_address("0x6969696969696969696969696969696969696969") 164 | 165 | fork = AnvilFork( 166 | fork_url=ETHEREUM_FULL_NODE_HTTP_URI, 167 | coinbase=FAKE_COINBASE, 168 | ) 169 | fork.mine() 170 | block = fork.w3.eth.get_block("latest") 171 | assert block["miner"] == FAKE_COINBASE 172 | 173 | 174 | def test_injecting_middleware(): 175 | fork = AnvilFork( 176 | fork_url="https://rpc.ankr.com/polygon", 177 | fork_block=53178474 - 1, 178 | middlewares=[ 179 | (web3.middleware.geth_poa.geth_poa_middleware, 0), 180 | ], 181 | ) 182 | set_web3(fork.w3) 183 | -------------------------------------------------------------------------------- /tests/uniswap/v3/libraries/test_tick_bitmap.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | import pytest 4 | from degenbot.exceptions import EVMRevertError, MissingTickWordError 5 | from degenbot.uniswap.v3_dataclasses import UniswapV3BitmapAtWord 6 | from degenbot.uniswap.v3_libraries import TickBitmap, TickMath 7 | 8 | # Tests adapted from Typescript tests on Uniswap V3 Github repo 9 | # ref: https://github.com/Uniswap/v3-core/blob/main/test/TickBitmap.spec.ts 10 | 11 | 12 | def is_initialized(tick_bitmap: Dict[int, UniswapV3BitmapAtWord], tick: int) -> bool: 13 | # Adapted from Uniswap test contract 14 | # ref: https://github.com/Uniswap/v3-core/blob/main/contracts/test/TickBitmapTest.sol 15 | 16 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord(tick_bitmap, tick, 1, True) 17 | return next == tick if initialized else False 18 | 19 | 20 | def empty_full_bitmap(spacing: int = 1) -> Dict[int, UniswapV3BitmapAtWord]: 21 | """ 22 | Generate a empty tick bitmap, maximum size, with the given tick spacing 23 | """ 24 | 25 | tick_bitmap = {} 26 | for tick in range(TickMath.MIN_TICK, TickMath.MAX_TICK, spacing): 27 | wordPos, _ = TickBitmap.position(tick=tick) 28 | tick_bitmap[wordPos] = UniswapV3BitmapAtWord() 29 | return tick_bitmap 30 | 31 | 32 | def empty_sparse_bitmap() -> dict[int, Any]: 33 | """ 34 | Generate a sparse, empty tick bitmap 35 | """ 36 | return dict() 37 | 38 | 39 | def test_isInitialized(): 40 | tick_bitmap = empty_full_bitmap() 41 | 42 | assert is_initialized(tick_bitmap, 1) is False 43 | 44 | TickBitmap.flipTick(tick_bitmap, 1, tick_spacing=1) 45 | assert is_initialized(tick_bitmap, 1) is True 46 | 47 | # TODO: The repo flips this tick twice, which may be a mistake 48 | # TickBitmap.flipTick(tick_bitmap, 1, tick_spacing=1) 49 | TickBitmap.flipTick(tick_bitmap, tick=1, tick_spacing=1) 50 | assert is_initialized(tick_bitmap, 1) is False 51 | 52 | TickBitmap.flipTick(tick_bitmap, tick=2, tick_spacing=1) 53 | assert is_initialized(tick_bitmap, 1) is False 54 | 55 | TickBitmap.flipTick(tick_bitmap, tick=1 + 256, tick_spacing=1) 56 | assert is_initialized(tick_bitmap, 257) is True 57 | assert is_initialized(tick_bitmap, 1) is False 58 | 59 | 60 | def test_flipTick() -> None: 61 | tick_bitmap = empty_full_bitmap() 62 | 63 | TickBitmap.flipTick(tick_bitmap, tick=-230, tick_spacing=1) 64 | assert is_initialized(tick_bitmap, -230) is True 65 | assert is_initialized(tick_bitmap, -231) is False 66 | assert is_initialized(tick_bitmap, -229) is False 67 | assert is_initialized(tick_bitmap, -230 + 256) is False 68 | assert is_initialized(tick_bitmap, -230 - 256) is False 69 | 70 | TickBitmap.flipTick(tick_bitmap, tick=-230, tick_spacing=1) 71 | assert is_initialized(tick_bitmap, -230) is False 72 | assert is_initialized(tick_bitmap, -231) is False 73 | assert is_initialized(tick_bitmap, -229) is False 74 | assert is_initialized(tick_bitmap, -230 + 256) is False 75 | assert is_initialized(tick_bitmap, -230 - 256) is False 76 | 77 | TickBitmap.flipTick(tick_bitmap, tick=-230, tick_spacing=1) 78 | TickBitmap.flipTick(tick_bitmap, tick=-259, tick_spacing=1) 79 | TickBitmap.flipTick(tick_bitmap, tick=-229, tick_spacing=1) 80 | TickBitmap.flipTick(tick_bitmap, tick=500, tick_spacing=1) 81 | TickBitmap.flipTick(tick_bitmap, tick=-259, tick_spacing=1) 82 | TickBitmap.flipTick(tick_bitmap, tick=-229, tick_spacing=1) 83 | TickBitmap.flipTick(tick_bitmap, tick=-259, tick_spacing=1) 84 | 85 | assert is_initialized(tick_bitmap, -259) is True 86 | assert is_initialized(tick_bitmap, -229) is False 87 | 88 | 89 | def test_flipTick_sparse() -> None: 90 | tick_bitmap = empty_sparse_bitmap() 91 | with pytest.raises(MissingTickWordError, match="Called flipTick on missing word"): 92 | TickBitmap.flipTick(tick_bitmap, tick=-230, tick_spacing=1) 93 | 94 | 95 | def test_incorrect_tick_spacing_flip() -> None: 96 | tick_spacing = 3 97 | tick_bitmap = empty_full_bitmap(tick_spacing) 98 | with pytest.raises(EVMRevertError, match="Tick not correctly spaced"): 99 | TickBitmap.flipTick(tick_bitmap, tick=2, tick_spacing=tick_spacing) 100 | 101 | 102 | def test_nextInitializedTickWithinOneWord() -> None: 103 | tick_bitmap: Dict[int, UniswapV3BitmapAtWord] = {} 104 | 105 | # set up a full-sized empty tick bitmap 106 | for tick in range(TickMath.MIN_TICK, TickMath.MAX_TICK): 107 | wordPos, _ = TickBitmap.position(tick=tick) 108 | if not tick_bitmap.get(wordPos): 109 | tick_bitmap[wordPos] = UniswapV3BitmapAtWord() 110 | 111 | # set the specified ticks to initialized 112 | for tick in [-200, -55, -4, 70, 78, 84, 139, 240, 535]: 113 | TickBitmap.flipTick(tick_bitmap=tick_bitmap, tick=tick, tick_spacing=1) 114 | 115 | # lte = false tests 116 | 117 | # returns tick to right if at initialized tick 118 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord( 119 | tick_bitmap=tick_bitmap, 120 | tick=78, 121 | tick_spacing=1, 122 | less_than_or_equal=False, 123 | ) 124 | assert next == 84 125 | assert initialized is True 126 | 127 | # returns tick to right if at initialized tick 128 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord( 129 | tick_bitmap=tick_bitmap, 130 | tick=-55, 131 | tick_spacing=1, 132 | less_than_or_equal=False, 133 | ) 134 | assert next == -4 135 | assert initialized is True 136 | 137 | # returns the tick directly to the right 138 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord( 139 | tick_bitmap=tick_bitmap, 140 | tick=77, 141 | tick_spacing=1, 142 | less_than_or_equal=False, 143 | ) 144 | assert next == 78 145 | assert initialized is True 146 | 147 | # returns the tick directly to the right 148 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord( 149 | tick_bitmap=tick_bitmap, 150 | tick=-56, 151 | tick_spacing=1, 152 | less_than_or_equal=False, 153 | ) 154 | assert next == -55 155 | assert initialized is True 156 | 157 | # returns the next words initialized tick if on the right boundary 158 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord( 159 | tick_bitmap=tick_bitmap, 160 | tick=255, 161 | tick_spacing=1, 162 | less_than_or_equal=False, 163 | ) 164 | assert next == 511 165 | assert initialized is False 166 | 167 | # returns the next words initialized tick if on the right boundary 168 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord( 169 | tick_bitmap=tick_bitmap, 170 | tick=-257, 171 | tick_spacing=1, 172 | less_than_or_equal=False, 173 | ) 174 | assert next == -200 175 | assert initialized is True 176 | 177 | # does not exceed boundary 178 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord( 179 | tick_bitmap=tick_bitmap, 180 | tick=508, 181 | tick_spacing=1, 182 | less_than_or_equal=False, 183 | ) 184 | assert next == 511 185 | assert initialized is False 186 | 187 | # skips entire word 188 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord( 189 | tick_bitmap=tick_bitmap, 190 | tick=255, 191 | tick_spacing=1, 192 | less_than_or_equal=False, 193 | ) 194 | assert next == 511 195 | assert initialized is False 196 | 197 | # skips half word 198 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord( 199 | tick_bitmap=tick_bitmap, 200 | tick=383, 201 | tick_spacing=1, 202 | less_than_or_equal=False, 203 | ) 204 | assert next == 511 205 | assert initialized is False 206 | 207 | # lte = true tests 208 | 209 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord( 210 | tick_bitmap=tick_bitmap, 211 | tick=78, 212 | tick_spacing=1, 213 | less_than_or_equal=True, 214 | ) 215 | assert next == 78 216 | assert initialized is True 217 | 218 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord( 219 | tick_bitmap=tick_bitmap, 220 | tick=79, 221 | tick_spacing=1, 222 | less_than_or_equal=True, 223 | ) 224 | assert next == 78 225 | assert initialized is True 226 | 227 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord( 228 | tick_bitmap=tick_bitmap, 229 | tick=258, 230 | tick_spacing=1, 231 | less_than_or_equal=True, 232 | ) 233 | assert next == 256 234 | assert initialized is False 235 | 236 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord( 237 | tick_bitmap=tick_bitmap, 238 | tick=256, 239 | tick_spacing=1, 240 | less_than_or_equal=True, 241 | ) 242 | assert next == 256 243 | assert initialized is False 244 | 245 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord( 246 | tick_bitmap=tick_bitmap, 247 | tick=72, 248 | tick_spacing=1, 249 | less_than_or_equal=True, 250 | ) 251 | assert next == 70 252 | assert initialized is True 253 | 254 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord( 255 | tick_bitmap=tick_bitmap, 256 | tick=-257, 257 | tick_spacing=1, 258 | less_than_or_equal=True, 259 | ) 260 | assert next == -512 261 | assert initialized is False 262 | 263 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord( 264 | tick_bitmap=tick_bitmap, 265 | tick=1023, 266 | tick_spacing=1, 267 | less_than_or_equal=True, 268 | ) 269 | assert next == 768 270 | assert initialized is False 271 | 272 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord( 273 | tick_bitmap=tick_bitmap, 274 | tick=900, 275 | tick_spacing=1, 276 | less_than_or_equal=True, 277 | ) 278 | assert next == 768 279 | assert initialized is False 280 | 281 | next, initialized = TickBitmap.nextInitializedTickWithinOneWord( 282 | tick_bitmap=tick_bitmap, 283 | tick=900, 284 | tick_spacing=1, 285 | less_than_or_equal=True, 286 | ) 287 | -------------------------------------------------------------------------------- /src/degenbot/fork/anvil_fork.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import socket 4 | import subprocess 5 | from typing import Any, Dict, Iterable, List, Literal, Tuple, cast 6 | 7 | import ujson 8 | from eth_typing import HexAddress 9 | from eth_utils.address import to_checksum_address 10 | from hexbytes import HexBytes 11 | from web3 import IPCProvider, Web3 12 | from web3.types import Middleware 13 | 14 | from ..constants import MAX_UINT256 15 | from ..logging import logger 16 | 17 | _SOCKET_READ_BUFFER_SIZE = 4096 # https://docs.python.org/3/library/socket.html#socket.socket.recv 18 | 19 | 20 | class AnvilFork: 21 | """ 22 | Launch an Anvil fork as a separate process and expose methods for commonly-used RPC calls. 23 | 24 | Provides a `Web3` connector to Anvil's IPC socket endpoint at the `.w3` attribute. 25 | """ 26 | 27 | def __init__( 28 | self, 29 | fork_url: str, 30 | fork_block: int | None = None, 31 | hardfork: str = "latest", 32 | gas_limit: int = 30_000_000, 33 | port: int | None = None, 34 | chain_id: int | None = None, 35 | mining_mode: Literal["auto", "interval", "none"] = "auto", 36 | mining_interval: int = 12, 37 | storage_caching: bool = True, 38 | base_fee: int | None = None, 39 | ipc_path: str | None = None, 40 | mnemonic: str = ( 41 | # Default mnemonic used by Brownie for Ganache forks 42 | "patient rude simple dog close planet oval animal hunt sketch suspect slim" 43 | ), 44 | coinbase: HexAddress | None = None, 45 | middlewares: List[Tuple[Middleware, int]] | None = None, 46 | balance_overrides: Iterable[Tuple[HexAddress, int]] | None = None, 47 | bytecode_overrides: Iterable[Tuple[HexAddress, bytes]] | None = None, 48 | ipc_provider_kwargs: Dict[str, Any] | None = None, 49 | ): 50 | def get_free_port_number() -> int: 51 | with socket.socket() as sock: 52 | sock.bind(("", 0)) 53 | _, port = sock.getsockname() 54 | return cast(int, port) 55 | 56 | if shutil.which("anvil") is None: # pragma: no cover 57 | raise Exception("Anvil is not installed or not accessible in the current path.") 58 | 59 | self.port = port if port is not None else get_free_port_number() 60 | 61 | ipc_path = f"/tmp/anvil-{self.port}.ipc" if ipc_path is None else ipc_path 62 | 63 | command = [] 64 | command.append("anvil") 65 | command.append("--silent") 66 | command.append("--auto-impersonate") 67 | command.append("--no-rate-limit") 68 | command.append(f"--fork-url={fork_url}") 69 | command.append(f"--hardfork={hardfork}") 70 | command.append(f"--gas-limit={gas_limit}") 71 | command.append(f"--port={self.port}") 72 | command.append(f"--ipc={ipc_path}") 73 | command.append(f"--mnemonic={mnemonic}") 74 | if fork_block: 75 | command.append(f"--fork-block-number={fork_block}") 76 | if chain_id: 77 | command.append(f"--chain-id={chain_id}") 78 | if base_fee: 79 | command.append(f"--base-fee={base_fee}") 80 | if storage_caching is False: 81 | command.append("--no-storage-caching") 82 | match mining_mode: 83 | case "auto": 84 | pass 85 | case "interval": 86 | logger.debug(f"Using 'interval' mining with {mining_interval}s block times.") 87 | command.append(f"--block-time={mining_interval}") 88 | case "none": 89 | command.append("--no-mining") 90 | command.append("--order=fifo") 91 | case _: 92 | raise ValueError(f"Unknown mining mode '{mining_mode}'.") 93 | 94 | self._process = subprocess.Popen(command) 95 | self.fork_url = fork_url 96 | self.http_url = f"http://localhost:{self.port}" 97 | self.ws_url = f"ws://localhost:{self.port}" 98 | self.ipc_path = ipc_path 99 | 100 | if ipc_provider_kwargs is None: 101 | ipc_provider_kwargs = dict() 102 | self.w3 = Web3(IPCProvider(ipc_path=ipc_path, **ipc_provider_kwargs)) 103 | 104 | if middlewares is not None: 105 | for middleware, layer in middlewares: 106 | self.w3.middleware_onion.inject(middleware, layer=layer) 107 | 108 | while self.w3.is_connected() is False: 109 | continue 110 | 111 | self.socket = socket.socket(socket.AF_UNIX) 112 | self.socket.connect(self.ipc_path) 113 | 114 | self._initial_block_number = ( 115 | fork_block if fork_block is not None else self.w3.eth.get_block_number() 116 | ) 117 | self.chain_id = chain_id if chain_id is not None else self.w3.eth.chain_id 118 | 119 | if balance_overrides is not None: 120 | for account, balance in balance_overrides: 121 | self.set_balance(account, balance) 122 | 123 | if bytecode_overrides is not None: 124 | for account, bytecode in bytecode_overrides: 125 | self.set_code(account, bytecode) 126 | 127 | if coinbase is not None: 128 | self.set_coinbase(coinbase) 129 | 130 | def __del__(self) -> None: 131 | self._process.terminate() 132 | self._process.wait() 133 | try: 134 | os.remove(self.ipc_path) 135 | except Exception: 136 | pass 137 | 138 | def _socket_request(self, method: str, params: List[Any] | None = None) -> None: 139 | """ 140 | Send a JSON-formatted request through the socket. 141 | """ 142 | if params is None: 143 | params = [] 144 | 145 | self.socket.sendall( 146 | bytes( 147 | ujson.dumps( 148 | { 149 | "jsonrpc": "2.0", 150 | "id": None, 151 | "method": method, 152 | "params": params, 153 | } 154 | ), 155 | encoding="utf-8", 156 | ), 157 | ) 158 | 159 | def _socket_response(self) -> Any: 160 | """ 161 | Read the response payload from socket and return the JSON-decoded result. 162 | """ 163 | raw_response = b"" 164 | response: Dict[str, Any] 165 | while True: 166 | try: 167 | raw_response += self.socket.recv(_SOCKET_READ_BUFFER_SIZE).rstrip() 168 | response = ujson.loads(raw_response) 169 | break 170 | except socket.timeout: # pragma: no cover 171 | continue 172 | except ujson.JSONDecodeError: # pragma: no cover 173 | # Typically thrown if a long response does not fit in buffer length 174 | continue 175 | 176 | if response.get("error"): 177 | raise Exception(f"Error in response: {response}") 178 | return response["result"] 179 | 180 | def create_access_list(self, transaction: Dict[Any, Any]) -> Any: 181 | # Exclude transaction values that are irrelevant for the JSON-RPC method 182 | # ref: https://docs.infura.io/networks/ethereum/json-rpc-methods/eth_createaccesslist 183 | keys_to_drop = ("gasPrice", "maxFeePerGas", "maxPriorityFeePerGas", "gas", "chainId") 184 | sanitized_tx = {k: v for k, v in transaction.items() if k not in keys_to_drop} 185 | 186 | # Apply int->hex conversion to some transaction values 187 | # ref: https://docs.infura.io/networks/ethereum/json-rpc-methods/eth_createaccesslist 188 | keys_to_hexify = ("value", "nonce") 189 | for key in keys_to_hexify: 190 | if key in sanitized_tx and isinstance(sanitized_tx[key], int): 191 | sanitized_tx[key] = hex(sanitized_tx[key]) 192 | 193 | self._socket_request( 194 | method="eth_createAccessList", 195 | params=[sanitized_tx], 196 | ) 197 | response: Dict[Any, Any] = self._socket_response() 198 | return response["accessList"] 199 | 200 | def mine(self) -> None: 201 | self._socket_request(method="evm_mine") 202 | self._socket_response() 203 | 204 | def reset( 205 | self, 206 | fork_url: str | None = None, 207 | block_number: int | None = None, 208 | base_fee: int | None = None, 209 | ) -> None: 210 | forking_params: Dict[str, Any] = { 211 | "jsonRpcUrl": fork_url if fork_url is not None else self.fork_url, 212 | "blockNumber": block_number if block_number is not None else self._initial_block_number, 213 | } 214 | 215 | self._socket_request( 216 | method="anvil_reset", 217 | params=[{"forking": forking_params}], 218 | ) 219 | self._socket_response() 220 | 221 | if fork_url is not None: 222 | self.fork_url = fork_url 223 | if base_fee is not None: 224 | self.set_next_base_fee(base_fee) 225 | 226 | def return_to_snapshot(self, id: int) -> bool: 227 | if id < 0: 228 | raise ValueError("ID cannot be negative") 229 | self._socket_request( 230 | method="evm_revert", 231 | params=[id], 232 | ) 233 | return bool(self._socket_response()) 234 | 235 | def set_balance(self, address: str, balance: int) -> None: 236 | if not (0 <= balance <= MAX_UINT256): 237 | raise ValueError("Invalid balance, must be within range: 0 <= balance <= 2**256 - 1") 238 | self._socket_request( 239 | method="anvil_setBalance", 240 | params=[ 241 | to_checksum_address(address), 242 | hex(balance), 243 | ], 244 | ) 245 | self._socket_response() 246 | 247 | def set_code(self, address: str, bytecode: bytes) -> None: 248 | self._socket_request( 249 | method="anvil_setCode", 250 | params=[ 251 | HexBytes(address).hex(), 252 | HexBytes(bytecode).hex(), 253 | ], 254 | ) 255 | self._socket_response() 256 | 257 | def set_coinbase(self, address: str) -> None: 258 | self._socket_request( 259 | method="anvil_setCoinbase", 260 | params=[HexBytes(address).hex()], 261 | ) 262 | self._socket_response() 263 | 264 | def set_next_base_fee(self, fee: int) -> None: 265 | if not (0 <= fee <= MAX_UINT256): 266 | raise ValueError("Fee outside valid range 0 <= fee <= 2**256-1") 267 | self._socket_request( 268 | method="anvil_setNextBlockBaseFeePerGas", 269 | params=[hex(fee)], 270 | ) 271 | self._socket_response() 272 | self.base_fee_next = fee 273 | 274 | def set_snapshot(self) -> int: 275 | self._socket_request( 276 | method="evm_snapshot", 277 | ) 278 | return int(self._socket_response(), 16) 279 | -------------------------------------------------------------------------------- /tests/uniswap/v3/libraries/test_sqrt_price_math.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal, getcontext 2 | 3 | import pytest 4 | from degenbot.constants import MIN_UINT128, MAX_UINT128, MAX_UINT256 5 | from degenbot.exceptions import EVMRevertError 6 | from degenbot.uniswap.v3_libraries import SqrtPriceMath 7 | 8 | # Adapted from Typescript tests on Uniswap V3 Github repo 9 | # ref: https://github.com/Uniswap/v3-core/blob/main/test/SqrtPriceMath.spec.ts 10 | 11 | 12 | getcontext().prec = ( 13 | 40 14 | # Match the decimal places value specified in Uniswap tests 15 | # ref: https://github.com/Uniswap/v3-core/blob/d8b1c635c275d2a9450bd6a78f3fa2484fef73eb/test/shared/utilities.ts#L60 16 | ) 17 | 18 | getcontext().rounding = ( 19 | # Change the rounding method to match the BigNumber rounding mode "3", 20 | # which is 'ROUND_FLOOR' per https://mikemcl.github.io/bignumber.js/#bignumber 21 | # ref: https://github.com/Uniswap/v3-core/blob/d8b1c635c275d2a9450bd6a78f3fa2484fef73eb/test/shared/utilities.ts#L69 22 | "ROUND_FLOOR" 23 | ) 24 | 25 | 26 | def expandTo18Decimals(x: int): 27 | return x * 10**18 28 | 29 | 30 | def encodePriceSqrt(reserve1: int, reserve0: int): 31 | """ 32 | Returns the sqrt price as a Q64.96 value 33 | """ 34 | return ((Decimal(reserve1) / Decimal(reserve0)).sqrt() * Decimal(2**96)).to_integral_value() 35 | 36 | 37 | def test_getNextSqrtPriceFromInput(): 38 | # fails if price is zero 39 | with pytest.raises(EVMRevertError): 40 | # this test should fail 41 | SqrtPriceMath.getNextSqrtPriceFromInput(0, 0, expandTo18Decimals(1) // 10, False) 42 | 43 | # fails if liquidity is zero 44 | with pytest.raises(EVMRevertError): 45 | # this test should fail 46 | SqrtPriceMath.getNextSqrtPriceFromInput(1, 0, expandTo18Decimals(1) // 10, True) 47 | 48 | # fails if input amount overflows the price 49 | price = 2**160 - 1 50 | liquidity = 1024 51 | amountIn = 1024 52 | with pytest.raises(EVMRevertError): 53 | # this test should fail 54 | SqrtPriceMath.getNextSqrtPriceFromInput(price, liquidity, amountIn, False) 55 | 56 | # any input amount cannot underflow the price 57 | price = 1 58 | liquidity = 1 59 | amountIn = 2**255 60 | assert SqrtPriceMath.getNextSqrtPriceFromInput(price, liquidity, amountIn, True) == 1 61 | 62 | # returns input price if amount in is zero and zeroForOne = true 63 | price = encodePriceSqrt(1, 1) 64 | assert ( 65 | SqrtPriceMath.getNextSqrtPriceFromInput(price, expandTo18Decimals(1) // 10, 0, True) 66 | == price 67 | ) 68 | 69 | # returns input price if amount in is zero and zeroForOne = false 70 | price = encodePriceSqrt(1, 1) 71 | assert ( 72 | SqrtPriceMath.getNextSqrtPriceFromInput(price, expandTo18Decimals(1) // 10, 0, False) 73 | == price 74 | ) 75 | 76 | # returns the minimum price for max inputs 77 | sqrtP = 2**160 - 1 78 | liquidity = MAX_UINT128 79 | maxAmountNoOverflow = MAX_UINT256 - ((liquidity << 96) // sqrtP) 80 | assert SqrtPriceMath.getNextSqrtPriceFromInput(sqrtP, liquidity, maxAmountNoOverflow, True) == 1 81 | 82 | # input amount of 0.1 token1 83 | sqrtQ = SqrtPriceMath.getNextSqrtPriceFromInput( 84 | encodePriceSqrt(1, 1), 85 | expandTo18Decimals(1), 86 | expandTo18Decimals(1) // 10, 87 | False, 88 | ) 89 | assert sqrtQ == 87150978765690771352898345369 90 | 91 | # input amount of 0.1 token0 92 | sqrtQ = SqrtPriceMath.getNextSqrtPriceFromInput( 93 | encodePriceSqrt(1, 1), 94 | expandTo18Decimals(1), 95 | expandTo18Decimals(1) // 10, 96 | True, 97 | ) 98 | assert sqrtQ == 72025602285694852357767227579 99 | 100 | # amountIn > type(uint96).max and zeroForOne = true 101 | assert ( 102 | SqrtPriceMath.getNextSqrtPriceFromInput( 103 | encodePriceSqrt(1, 1), expandTo18Decimals(10), 2**100, True 104 | ) 105 | == 624999999995069620 106 | ) 107 | # perfect answer: https://www.wolframalpha.com/input/?i=624999999995069620+-+%28%281e19+*+1+%2F+%281e19+%2B+2%5E100+*+1%29%29+*+2%5E96%29 108 | 109 | # can return 1 with enough amountIn and zeroForOne = true 110 | assert ( 111 | SqrtPriceMath.getNextSqrtPriceFromInput(encodePriceSqrt(1, 1), 1, MAX_UINT256 // 2, True) 112 | == 1 113 | ) 114 | 115 | 116 | def test_getNextSqrtPriceFromOutput(): 117 | with pytest.raises(EVMRevertError): 118 | # this test should fail 119 | SqrtPriceMath.getNextSqrtPriceFromOutput(0, 0, expandTo18Decimals(1) // 10, False) 120 | 121 | with pytest.raises(EVMRevertError): 122 | # this test should fail 123 | SqrtPriceMath.getNextSqrtPriceFromOutput(1, 0, expandTo18Decimals(1) // 10, True) 124 | 125 | price = 20282409603651670423947251286016 126 | liquidity = 1024 127 | amountOut = 4 128 | with pytest.raises(EVMRevertError): 129 | # this test should fail 130 | SqrtPriceMath.getNextSqrtPriceFromOutput(price, liquidity, amountOut, False) 131 | 132 | price = 20282409603651670423947251286016 133 | liquidity = 1024 134 | amountOut = 5 135 | with pytest.raises(EVMRevertError): 136 | # this test should fail 137 | assert SqrtPriceMath.getNextSqrtPriceFromOutput(price, liquidity, amountOut, False) 138 | 139 | price = 20282409603651670423947251286016 140 | liquidity = 1024 141 | amountOut = 262145 142 | with pytest.raises(EVMRevertError): 143 | # this test should fail 144 | SqrtPriceMath.getNextSqrtPriceFromOutput(price, liquidity, amountOut, True) 145 | 146 | price = 20282409603651670423947251286016 147 | liquidity = 1024 148 | amountOut = 262144 149 | with pytest.raises(EVMRevertError): 150 | # this test should fail 151 | SqrtPriceMath.getNextSqrtPriceFromOutput(price, liquidity, amountOut, True) 152 | 153 | price = 20282409603651670423947251286016 154 | liquidity = 1024 155 | amountOut = 262143 156 | sqrtQ = SqrtPriceMath.getNextSqrtPriceFromOutput(price, liquidity, amountOut, True) 157 | assert sqrtQ == 77371252455336267181195264 158 | 159 | price = 20282409603651670423947251286016 160 | liquidity = 1024 161 | amountOut = 4 162 | 163 | with pytest.raises(EVMRevertError): 164 | # this test should fail 165 | SqrtPriceMath.getNextSqrtPriceFromOutput(price, liquidity, amountOut, False) 166 | 167 | price = encodePriceSqrt(1, 1) 168 | assert ( 169 | SqrtPriceMath.getNextSqrtPriceFromOutput(price, expandTo18Decimals(1) // 10, 0, True) 170 | == price 171 | ) 172 | 173 | price = encodePriceSqrt(1, 1) 174 | assert ( 175 | SqrtPriceMath.getNextSqrtPriceFromOutput(price, expandTo18Decimals(1) // 10, 0, False) 176 | == price 177 | ) 178 | 179 | sqrtQ = SqrtPriceMath.getNextSqrtPriceFromOutput( 180 | encodePriceSqrt(1, 1), 181 | expandTo18Decimals(1), 182 | expandTo18Decimals(1) // 10, 183 | False, 184 | ) 185 | assert sqrtQ == 88031291682515930659493278152 186 | 187 | sqrtQ = SqrtPriceMath.getNextSqrtPriceFromOutput( 188 | encodePriceSqrt(1, 1), 189 | expandTo18Decimals(1), 190 | expandTo18Decimals(1) // 10, 191 | True, 192 | ) 193 | assert sqrtQ == 71305346262837903834189555302 194 | 195 | with pytest.raises(EVMRevertError): 196 | # this test should fail 197 | SqrtPriceMath.getNextSqrtPriceFromOutput(encodePriceSqrt(1, 1), 1, MAX_UINT256, True) 198 | 199 | with pytest.raises(EVMRevertError): 200 | # this test should fail 201 | SqrtPriceMath.getNextSqrtPriceFromOutput(encodePriceSqrt(1, 1), 1, MAX_UINT256, False) 202 | 203 | 204 | def test_getAmount0Delta(): 205 | with pytest.raises(EVMRevertError): 206 | SqrtPriceMath.getAmount0Delta(0, 0, 0, True) 207 | 208 | with pytest.raises(EVMRevertError): 209 | SqrtPriceMath.getAmount0Delta(1, 0, 0, True) 210 | 211 | with pytest.raises(EVMRevertError): 212 | SqrtPriceMath.getAmount0Delta(1, 0, MAX_UINT128 + 1) 213 | 214 | amount0 = SqrtPriceMath.getAmount0Delta(encodePriceSqrt(1, 1), encodePriceSqrt(2, 1), 0, True) 215 | assert amount0 == 0 216 | 217 | amount0 = SqrtPriceMath.getAmount0Delta(encodePriceSqrt(1, 1), encodePriceSqrt(1, 1), 0, True) 218 | assert amount0 == 0 219 | 220 | amount0 = SqrtPriceMath.getAmount0Delta( 221 | encodePriceSqrt(1, 1), 222 | encodePriceSqrt(121, 100), 223 | expandTo18Decimals(1), 224 | True, 225 | ) 226 | assert amount0 == 90909090909090910 227 | 228 | amount0RoundedDown = SqrtPriceMath.getAmount0Delta( 229 | encodePriceSqrt(1, 1), 230 | encodePriceSqrt(121, 100), 231 | expandTo18Decimals(1), 232 | False, 233 | ) 234 | assert amount0RoundedDown == amount0 - 1 235 | 236 | amount0Up = SqrtPriceMath.getAmount0Delta( 237 | encodePriceSqrt(2**90, 1), 238 | encodePriceSqrt(2**96, 1), 239 | expandTo18Decimals(1), 240 | True, 241 | ) 242 | amount0Down = SqrtPriceMath.getAmount0Delta( 243 | encodePriceSqrt(2**90, 1), 244 | encodePriceSqrt(2**96, 1), 245 | expandTo18Decimals(1), 246 | False, 247 | ) 248 | assert amount0Up == amount0Down + 1 249 | 250 | 251 | def test_getAmount1Delta(): 252 | SqrtPriceMath.getAmount1Delta(0, 1, MAX_UINT128 - 1, False) 253 | SqrtPriceMath.getAmount1Delta(1, 0, MAX_UINT128 - 1, False) 254 | SqrtPriceMath.getAmount1Delta(0, 1, MAX_UINT128 - 1, True) 255 | SqrtPriceMath.getAmount1Delta(1, 0, MAX_UINT128 - 1, True) 256 | 257 | SqrtPriceMath.getAmount1Delta(0, 0, MIN_UINT128 - 1) 258 | SqrtPriceMath.getAmount1Delta(0, 0, MIN_UINT128 - 1) 259 | 260 | amount1 = SqrtPriceMath.getAmount1Delta(encodePriceSqrt(1, 1), encodePriceSqrt(2, 1), 0, True) 261 | assert amount1 == 0 262 | 263 | amount1 = SqrtPriceMath.getAmount0Delta(encodePriceSqrt(1, 1), encodePriceSqrt(1, 1), 0, True) 264 | assert amount1 == 0 265 | 266 | # returns 0.1 amount1 for price of 1 to 1.21 267 | amount1 = SqrtPriceMath.getAmount1Delta( 268 | encodePriceSqrt(1, 1), 269 | encodePriceSqrt(121, 100), 270 | expandTo18Decimals(1), 271 | True, 272 | ) 273 | assert amount1 == 100000000000000000 274 | 275 | amount1RoundedDown = SqrtPriceMath.getAmount1Delta( 276 | encodePriceSqrt(1, 1), 277 | encodePriceSqrt(121, 100), 278 | expandTo18Decimals(1), 279 | False, 280 | ) 281 | assert amount1RoundedDown == amount1 - 1 282 | 283 | 284 | def test_swap_computation(): 285 | sqrtP = 1025574284609383690408304870162715216695788925244 286 | liquidity = 50015962439936049619261659728067971248 287 | zeroForOne = True 288 | amountIn = 406 289 | 290 | sqrtQ = SqrtPriceMath.getNextSqrtPriceFromInput(sqrtP, liquidity, amountIn, zeroForOne) 291 | assert sqrtQ == 1025574284609383582644711336373707553698163132913 292 | 293 | amount0Delta = SqrtPriceMath.getAmount0Delta(sqrtQ, sqrtP, liquidity, True) 294 | assert amount0Delta == 406 295 | -------------------------------------------------------------------------------- /tests/arbitrage/test_uniswap_curve_cycle.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import concurrent.futures 3 | import multiprocessing 4 | import pickle 5 | import time 6 | 7 | import pytest 8 | from degenbot.arbitrage.uniswap_curve_cycle import UniswapCurveCycle 9 | from degenbot.config import set_web3 10 | from degenbot.curve.curve_stableswap_liquidity_pool import CurveStableswapPool 11 | from degenbot.erc20_token import Erc20Token 12 | from degenbot.exceptions import ArbitrageError 13 | from degenbot.uniswap.v2_dataclasses import UniswapV2PoolState 14 | from degenbot.uniswap.v2_liquidity_pool import LiquidityPool 15 | 16 | WETH_ADDRESS = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" 17 | DAI_ADDRESS = "0x6B175474E89094C44Da98b954EedeAC495271d0F" 18 | USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" 19 | USDT_ADDRESS = "0xdAC17F958D2ee523a2206206994597C13D831ec7" 20 | CURVE_TRIPOOL_ADDRESS = "0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7" 21 | UNISWAP_V2_WETH_DAI_ADDRESS = "0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11" 22 | UNISWAP_V2_WETH_USDC_ADDRESS = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc" 23 | UNISWAP_V2_WETH_USDT_ADDRESS = "0x0d4a11d5EEaaC28EC3F61d100daF4d40471f1852" 24 | FAKE_ADDRESS = "0x6942000000000000000000000000000000000000" 25 | 26 | 27 | def test_create_arb(ethereum_full_node_web3): 28 | set_web3(ethereum_full_node_web3) 29 | uniswap_v2_weth_dai_lp = LiquidityPool(UNISWAP_V2_WETH_DAI_ADDRESS) 30 | curve_tripool = CurveStableswapPool(CURVE_TRIPOOL_ADDRESS) 31 | uniswap_v2_weth_usdc_lp = LiquidityPool(UNISWAP_V2_WETH_USDC_ADDRESS) 32 | 33 | weth = Erc20Token(WETH_ADDRESS) 34 | UniswapCurveCycle( 35 | input_token=weth, 36 | swap_pools=[uniswap_v2_weth_dai_lp, curve_tripool, uniswap_v2_weth_usdc_lp], 37 | id="test", 38 | max_input=10 * 10**18, 39 | ) 40 | 41 | 42 | def test_pickle_arb(ethereum_full_node_web3): 43 | set_web3(ethereum_full_node_web3) 44 | uniswap_v2_weth_dai_lp = LiquidityPool(UNISWAP_V2_WETH_DAI_ADDRESS) 45 | curve_tripool = CurveStableswapPool(CURVE_TRIPOOL_ADDRESS) 46 | uniswap_v2_weth_usdc_lp = LiquidityPool(UNISWAP_V2_WETH_USDC_ADDRESS) 47 | 48 | weth = Erc20Token(WETH_ADDRESS) 49 | arb = UniswapCurveCycle( 50 | input_token=weth, 51 | swap_pools=[uniswap_v2_weth_dai_lp, curve_tripool, uniswap_v2_weth_usdc_lp], 52 | id="test", 53 | max_input=10 * 10**18, 54 | ) 55 | pickle.dumps(arb) 56 | 57 | 58 | def test_arb_calculation(ethereum_full_node_web3): 59 | set_web3(ethereum_full_node_web3) 60 | curve_tripool = CurveStableswapPool(CURVE_TRIPOOL_ADDRESS) 61 | uniswap_v2_weth_dai_lp = LiquidityPool(UNISWAP_V2_WETH_DAI_ADDRESS) 62 | uniswap_v2_weth_usdc_lp = LiquidityPool(UNISWAP_V2_WETH_USDC_ADDRESS) 63 | uniswap_v2_weth_usdt_lp = LiquidityPool(UNISWAP_V2_WETH_USDT_ADDRESS) 64 | 65 | weth = Erc20Token(WETH_ADDRESS) 66 | 67 | for swap_pools in [ 68 | (uniswap_v2_weth_dai_lp, curve_tripool, uniswap_v2_weth_usdc_lp), 69 | (uniswap_v2_weth_dai_lp, curve_tripool, uniswap_v2_weth_usdt_lp), 70 | (uniswap_v2_weth_usdc_lp, curve_tripool, uniswap_v2_weth_dai_lp), 71 | (uniswap_v2_weth_usdc_lp, curve_tripool, uniswap_v2_weth_usdt_lp), 72 | (uniswap_v2_weth_usdt_lp, curve_tripool, uniswap_v2_weth_dai_lp), 73 | (uniswap_v2_weth_usdt_lp, curve_tripool, uniswap_v2_weth_usdc_lp), 74 | ]: 75 | try: 76 | arb = UniswapCurveCycle( 77 | input_token=weth, 78 | swap_pools=swap_pools, 79 | id="test", 80 | max_input=10 * 10**18, 81 | ) 82 | arb.calculate() 83 | except ArbitrageError: 84 | pass 85 | 86 | 87 | def test_arb_payload_encoding(ethereum_full_node_web3): 88 | set_web3(ethereum_full_node_web3) 89 | curve_tripool = CurveStableswapPool(CURVE_TRIPOOL_ADDRESS) 90 | uniswap_v2_weth_dai_lp = LiquidityPool(UNISWAP_V2_WETH_DAI_ADDRESS) 91 | uniswap_v2_weth_usdc_lp = LiquidityPool(UNISWAP_V2_WETH_USDC_ADDRESS) 92 | uniswap_v2_weth_usdt_lp = LiquidityPool(UNISWAP_V2_WETH_USDT_ADDRESS) 93 | 94 | weth = Erc20Token(WETH_ADDRESS) 95 | 96 | # set up overrides for a profitable arbitrage condition 97 | v2_weth_dai_state_override = UniswapV2PoolState( 98 | pool=uniswap_v2_weth_dai_lp.address, 99 | reserves_token0=7154631418308101780013056, # DAI <----- overridden, added 10% to DAI supply 100 | reserves_token1=2641882268814772168174, # WETH 101 | ) 102 | v2_weth_usdc_lp_state_override = UniswapV2PoolState( 103 | pool=uniswap_v2_weth_usdc_lp.address, 104 | reserves_token0=51264330493455, # USDC 105 | reserves_token1=20822226989581225186276, # WETH 106 | ) 107 | v2_weth_usdt_lp_state_override = UniswapV2PoolState( 108 | pool=uniswap_v2_weth_usdt_lp.address, 109 | reserves_token0=33451964234532476269546, # WETH 110 | reserves_token1=82374477120833, # USDT 111 | ) 112 | 113 | overrides = [ 114 | (uniswap_v2_weth_dai_lp, v2_weth_dai_state_override), 115 | (uniswap_v2_weth_usdc_lp, v2_weth_usdc_lp_state_override), 116 | (uniswap_v2_weth_usdt_lp, v2_weth_usdt_lp_state_override), 117 | ] 118 | 119 | for swap_pools in [ 120 | (uniswap_v2_weth_dai_lp, curve_tripool, uniswap_v2_weth_usdc_lp), 121 | (uniswap_v2_weth_dai_lp, curve_tripool, uniswap_v2_weth_usdt_lp), 122 | # (uniswap_v2_weth_usdc_lp, curve_tripool, uniswap_v2_weth_dai_lp), 123 | # (uniswap_v2_weth_usdc_lp, curve_tripool, uniswap_v2_weth_usdt_lp), 124 | # (uniswap_v2_weth_usdt_lp, curve_tripool, uniswap_v2_weth_dai_lp), 125 | # (uniswap_v2_weth_usdt_lp, curve_tripool, uniswap_v2_weth_usdc_lp), 126 | ]: 127 | arb = UniswapCurveCycle( 128 | input_token=weth, 129 | swap_pools=swap_pools, 130 | id="test", 131 | max_input=10 * 10**18, 132 | ) 133 | 134 | try: 135 | calc_result = arb.calculate(override_state=overrides) 136 | except ArbitrageError: 137 | raise 138 | else: 139 | arb.generate_payloads( 140 | from_address=FAKE_ADDRESS, 141 | swap_amount=calc_result.input_amount, 142 | pool_swap_amounts=calc_result.swap_amounts, 143 | ) 144 | 145 | 146 | async def test_process_pool_calculation(ethereum_full_node_web3) -> None: 147 | set_web3(ethereum_full_node_web3) 148 | start = time.perf_counter() 149 | 150 | weth = Erc20Token(WETH_ADDRESS) 151 | curve_tripool = CurveStableswapPool(CURVE_TRIPOOL_ADDRESS) 152 | uniswap_v2_weth_dai_lp = LiquidityPool(UNISWAP_V2_WETH_DAI_ADDRESS) 153 | uniswap_v2_weth_usdc_lp = LiquidityPool(UNISWAP_V2_WETH_USDC_ADDRESS) 154 | uniswap_v2_weth_usdt_lp = LiquidityPool(UNISWAP_V2_WETH_USDT_ADDRESS) 155 | 156 | # Reserves taken from block 19050173 157 | # --- 158 | # 159 | # DAI-USDC-USDT (CurveStable, 0.01%) 160 | # • Token 0: DAI - Reserves: 42217927126053167268106015 161 | # • Token 1: USDC - Reserves: 41857454785332 162 | # • Token 2: USDT - Reserves: 116155337005450 163 | # DAI-WETH (V2, 0.30%) 164 | # • Token 0: DAI - Reserves: 6504210380280092514247627 165 | # • Token 1: WETH - Reserves: 2641882268814772168174 166 | # USDC-WETH (V2, 0.30%) 167 | # • Token 0: USDC - Reserves: 51264330493455 168 | # • Token 1: WETH - Reserves: 20822226989581225186276 169 | # WETH-USDT (V2, 0.30%) 170 | # • Token 0: WETH - Reserves: 33451964234532476269546 171 | # • Token 1: USDT - Reserves: 82374477120833 172 | 173 | # set up overrides for a profitable arbitrage condition 174 | v2_weth_dai_state_override = UniswapV2PoolState( 175 | pool=uniswap_v2_weth_dai_lp.address, 176 | reserves_token0=7154631418308101780013056, # DAI <----- overridden, added 10% to DAI supply 177 | reserves_token1=2641882268814772168174, # WETH 178 | ) 179 | v2_weth_usdc_lp_state_override = UniswapV2PoolState( 180 | pool=uniswap_v2_weth_usdc_lp.address, 181 | reserves_token0=51264330493455, # USDC 182 | reserves_token1=20822226989581225186276, # WETH 183 | ) 184 | v2_weth_usdt_lp_state_override = UniswapV2PoolState( 185 | pool=uniswap_v2_weth_usdt_lp.address, 186 | reserves_token0=33451964234532476269546, # WETH 187 | reserves_token1=82374477120833, # USDT 188 | ) 189 | 190 | overrides = [ 191 | (uniswap_v2_weth_dai_lp, v2_weth_dai_state_override), 192 | (uniswap_v2_weth_usdc_lp, v2_weth_usdc_lp_state_override), 193 | (uniswap_v2_weth_usdt_lp, v2_weth_usdt_lp_state_override), 194 | ] 195 | 196 | with concurrent.futures.ProcessPoolExecutor( 197 | mp_context=multiprocessing.get_context("spawn"), 198 | ) as executor: 199 | for swap_pools in [ 200 | (uniswap_v2_weth_dai_lp, curve_tripool, uniswap_v2_weth_usdc_lp), 201 | (uniswap_v2_weth_dai_lp, curve_tripool, uniswap_v2_weth_usdt_lp), 202 | # (uniswap_v2_weth_usdc_lp, curve_tripool, uniswap_v2_weth_dai_lp), 203 | # (uniswap_v2_weth_usdc_lp, curve_tripool, uniswap_v2_weth_usdt_lp), 204 | # (uniswap_v2_weth_usdt_lp, curve_tripool, uniswap_v2_weth_dai_lp), 205 | # (uniswap_v2_weth_usdt_lp, curve_tripool, uniswap_v2_weth_usdc_lp), 206 | ]: 207 | arb = UniswapCurveCycle( 208 | input_token=weth, 209 | swap_pools=swap_pools, 210 | id="test", 211 | max_input=10 * 10**18, 212 | ) 213 | 214 | future = await arb.calculate_with_pool(executor=executor, override_state=overrides) 215 | result = await future 216 | assert result 217 | 218 | # Saturate the process pool executor with multiple calculations. 219 | # Should reveal cases of excessive latency. 220 | _NUM_FUTURES = 512 221 | calculation_futures = [] 222 | for _ in range(_NUM_FUTURES): 223 | calculation_futures.append( 224 | await arb.calculate_with_pool( 225 | executor=executor, 226 | override_state=overrides, 227 | ) 228 | ) 229 | 230 | assert len(calculation_futures) == _NUM_FUTURES 231 | for i, task in enumerate(asyncio.as_completed(calculation_futures)): 232 | await task 233 | print(f"Completed process_pool calc #{i}, {time.perf_counter()-start:.2f}s since start") 234 | print(f"Completed {_NUM_FUTURES} calculations in {time.perf_counter() - start:.1f}s") 235 | 236 | 237 | def test_bad_pool_in_constructor(ethereum_full_node_web3): 238 | set_web3(ethereum_full_node_web3) 239 | weth = Erc20Token(WETH_ADDRESS) 240 | uniswap_v2_weth_dai_lp = LiquidityPool(UNISWAP_V2_WETH_DAI_ADDRESS) 241 | uniswap_v2_weth_usdc_lp = LiquidityPool(UNISWAP_V2_WETH_USDC_ADDRESS) 242 | 243 | with pytest.raises( 244 | ValueError, match="Must provide only Curve StableSwap or Uniswap liquidity pools." 245 | ): 246 | UniswapCurveCycle( 247 | input_token=weth, 248 | swap_pools=[uniswap_v2_weth_dai_lp, None, uniswap_v2_weth_usdc_lp], # type: ignore[list-item] 249 | id="test", 250 | max_input=10 * 10**18, 251 | ) 252 | 253 | 254 | def test_no_max_input(ethereum_full_node_web3): 255 | set_web3(ethereum_full_node_web3) 256 | weth = Erc20Token(WETH_ADDRESS) 257 | uniswap_v2_weth_dai_lp = LiquidityPool(UNISWAP_V2_WETH_DAI_ADDRESS) 258 | curve_tripool = CurveStableswapPool(CURVE_TRIPOOL_ADDRESS) 259 | uniswap_v2_weth_usdc_lp = LiquidityPool(UNISWAP_V2_WETH_USDC_ADDRESS) 260 | 261 | UniswapCurveCycle( 262 | id="test_arb", 263 | input_token=weth, 264 | swap_pools=[uniswap_v2_weth_dai_lp, curve_tripool, uniswap_v2_weth_usdc_lp], 265 | ) 266 | 267 | 268 | def test_zero_max_input(ethereum_full_node_web3): 269 | set_web3(ethereum_full_node_web3) 270 | weth = Erc20Token(WETH_ADDRESS) 271 | uniswap_v2_weth_dai_lp = LiquidityPool(UNISWAP_V2_WETH_DAI_ADDRESS) 272 | curve_tripool = CurveStableswapPool(CURVE_TRIPOOL_ADDRESS) 273 | uniswap_v2_weth_usdc_lp = LiquidityPool(UNISWAP_V2_WETH_USDC_ADDRESS) 274 | 275 | with pytest.raises(ValueError, match="Maximum input must be positive."): 276 | UniswapCurveCycle( 277 | id="test_arb", 278 | input_token=weth, 279 | swap_pools=[uniswap_v2_weth_dai_lp, curve_tripool, uniswap_v2_weth_usdc_lp], 280 | max_input=0, 281 | ) 282 | --------------------------------------------------------------------------------