A Rust MEV-bot core with a first-class Python driver shell, for Uniswap (V2, V3, V4), Curve V1, Solidly V2, Balancer V2, and Aave V3 integrations on EVM-compatible blockchains.
Degenbot has two equally first-class consumers sharing one Rust core:
- Pure-Rust MEV bot —
cargo add degenbot(the umbrella crate re-exporting the cores; a git/path dependency until the workspace is published to crates.io) and build a fully functional MEV bot in Rust only. - Python-driven MEV bot — drive the same Rust core from Python through a thin PyO3 layer that translates Python calls into Rust calls.
The Rust core is the engine; Python is a driver shell, not a co-implementation. Pool/token state, swap math, event decoding, solvers, the pump loop, and swap encoding all live in Rust core crates; the Python layer provides the user-facing API, orchestration, and configuration. See docs/adr/ADR-005-polars-inspired-three-layer-architecture.md for the architectural vision.
- Overview
- Architecture: The Python-Rust Split
- Installation
- Quick Start
- Supported Protocols
- Core Concepts
- Examples
- Bot API Reference
- CLI Reference
- Configuration
- The Rust Core
- Documentation
- Contributing
- License
- Donation
Debugging a failing settlement-arbitrage path, or building your own simulation harness? See
INVESTIGATIONS.md— the simulation oracle driver, the per-contract scaffolder, and the path-fixture toolkit.
Degenbot abstracts the implementation details of Uniswap liquidity pools and their underlying ERC-20 tokens into a set of Rust core crates exposed to Python through a thin PyO3 binding layer. The Rust core owns all performance-critical and stateful logic — pool/token state, swap math, event decoding, solvers, the pump loop, and swap encoding — while the Python companion provides the user-facing API, docstrings, and I/O orchestration.
As of the 0.6.x series the Rust core also owns the operator-facing infrastructure: the settlement-arbitrage engine and pump loop, the in-process revm simulation engine, on-chain price readers, the DB-aware pool/Aave updaters, EIP-1559 transaction signing and submission, and WS/HTTP pub-sub. Python still owns the user-facing API, config, and registries, and — until the ADR-010 0.7 cutover — the SQLAlchemy ORM with its Alembic-stamped session; the Rust degenbot-db crate already owns the schema DDL and file operations behind it (see degenbot database cutover / degenbot database heal).
These classes serve as building blocks for the lessons published by BowTiedDevil on Degen Code.
Ownership is strict, which is what makes both consumption paths first-class: the Rust core owns everything stateful and performance-critical — pool/token state, swap math, event decoding, solvers, the pump loop, and swap encoding — while the Python side owns the user-facing API, orchestration, and configuration. The core crates contain no PyO3 code at all, so a pure-Rust bot (cargo add degenbot) runs without any Python machinery; an in-repo proof is rust/crates/degenbot/examples/standalone_consumer.rs. Architectural decisions — state ownership (ADR-003), FFI topology (ADR-005), schema cutover (ADR-010) — are recorded in the ADR design log, with the crate sources as the last word.
- Python 3.12+
pip,uv, or similar package management tool
pip install degenbot
git clone https://github.com/BowTiedDevil/degenbot.git cd degenbot uv sync # or: pip install -e .
The Bot class is the central session object for all degenbot operations. It manages connections, registries, and provides factory methods for creating pools and tokens:
# Initialize Bot from config file or explicit settings bot = degenbot.Bot( config=DegenbotConfig( default_chain_id=1, rpc={1: RPC_URL}, database={"path": "~/.config/degenbot/degenbot.db"}, ) ) # Bot constructs the RPC provider from config and enforces its # eth_chainId matches default_chain_id (fail-fast). No manual provider # registration is needed.
# Create pools and tokens through Bot (I/O-free when possible)
pool = bot.build_pool("0x8ad599c3A0ff1De082011EFDDc58f1908EB6e6D8")
token = bot.build_erc20token("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") # WETH
# Pools are I/O-free - all data injected at construction
print(f"Pool: {pool.name}")
print(f"Token: {token}")
# Calculate swaps without any network calls
amount_out = pool.calculate_tokens_out_from_tokens_in(
token_in=pool.token0,
token_in_quantity=10**18,
)
print(f"Output: {amount_out}")
Pool classes are Python companions over Rust-owned pool state — direct construction is impossible (any constructor call raises TypeError); a pool comes into being only by registering in a Bot's Rust state. Use Bot.build_pool() in production (or the make_*_pool test helpers in tests):
# Do NOT do this — the constructor always raises TypeError: try: degenbot.UniswapV3Pool("0x8ad599c3A0ff1De082011EFDDc58f1908EB6e6D8") # ← BROKEN! raise AssertionError("direct construction of a pool should be impossible") except TypeError: pass # Instead, always use Bot to construct pools (registers in Rust state, # returns the Python companion wrapper): pool = bot.build_pool("0x8ad599c3A0ff1De082011EFDDc58f1908EB6e6D8")
Degenbot pools follow an I/O-free architecture where on-chain data is fetched at construction time and injected into pool objects. After construction, pools are pure calculation objects with no network dependencies. For the Uniswap V2/V3/V4 families (including Aerodrome and Balancer), Bot.build_pool() performs that full choreography on the Rust side; Curve pools and token metadata use the equivalent Python-side builders. Either way, the pool you receive needs no network access at construction time.
Benefits:
- Testability: Easy to create test fixtures with mocked data
- Performance: Swap calculations are pure math, no network calls
- Reliability: No async complexity in pool logic
- State Management: Pools can be snapshotted, pickled, and restored
Current status: all pool types (V2, V3, V4, Aerodrome, Camelot, Balancer, Curve) are fully I/O-free — no pool class carries provider-dependent methods. For the Uniswap V2/V3/V4 families (including Aerodrome and Balancer), Bot.build_pool() performs the full fetch-and-register choreography and bot.update(pool) refreshes state from chain; Curve pools and token metadata use the remaining Python builders. Either way, state changes enter a pool only as a validated external_update() message — the pool itself never does I/O.
Bot is the central session object that owns all runtime state:
import degenbot from degenbot.config import DegenbotConfig # Bot manages connections, registries, and provides factory methods bot = degenbot.Bot( config=DegenbotConfig( default_chain_id=1, rpc={1: RPC_URL}, database={"path": ":memory:"}, ) ) # The RPC provider is built from config; eth_chainId is enforced to equal # default_chain_id at construction. bot.provider # the chain's AlloyProvider (chain_id enforced at construction) bot.chain_id # 1
# All pool/token creation flows through Bot pool = bot.build_pool("0x8ad599c3A0ff1De082011EFDDc58f1908EB6e6D8") token = bot.build_erc20token("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") # Bot provides token utilities with caching balance = bot.get_token_balance(token, "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045") approval = bot.get_token_approval(token, owner="0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", spender="0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45")
Bot properties:
bot.chain_id- the configured chain ID for this single-chain sessionbot.provider/bot.get_provider()- the chain'sAlloyProvider(chain_id enforced at construction)bot.pools- PoolRegistry for created poolsbot.tokens- TokenRegistry for created tokensbot.managed_pools- ManagedPoolRegistry for V4 poolsbot.db- DatabaseSessionManager for state snapshots
Lifecycle & refresh: Bot is a context manager — with degenbot.Bot(config=...) as bot: (or an explicit, idempotent bot.close()) tears down the provider, the scoped DB session, and the Rust engine handles. bot.update(pool, block_number=...) is the canonical refresh entry point for the V2/V3/V4 families: it fetches current chain state from the Rust core and pushes pool.external_update() (returns True only when state changed). bot.release_python_state() drops the Python-side tracker/snapshot caches once the Rust engine owns canonical state. Builders are internal to Bot and not exposed publicly. All pool/token creation goes through Bot.build_pool().
build_pool(address) is the universal entry point that auto-resolves pool type from DB, registry, and on-chain probing:
| Pool Type | Method | Supports |
|---|---|---|
| Uniswap V2 | bot.build_pool(address) |
Standard AMM, Camelot, other forks |
| Uniswap V3 | bot.build_pool(address) |
Full tick data, range orders |
| Uniswap V4 | bot.build_managed_pool(address, pool_id=...) |
Singleton architecture with hooks |
| Curve V1 | bot.build_pool(address) |
StableSwap, metapools, lending pools |
When build_pool is called, the pool type is auto-resolved — in order, from the pool registry, the database, and (as a last resort) on-chain probing of the pool contract.
Pools receive state updates via external_update() — a pure-logic method that validates the update and transitions pool state. I/O never touches the pool itself: for the V2/V3/V4 families bot.update(pool) fetches current reserves/slot0/liquidity from the Rust core (Curve/Balancer refresh runs through the remaining Python builders), constructs the family's ExternalUpdate message, and pushes it to the pool:
# Builder fetches state from chain (I/O), constructs update, pushes to pool update = UniswapV2PoolExternalUpdate( block_number=block_number, reserves_token0=reserves0, reserves_token1=reserves1, ) pool.external_update(update) # Pure logic — no I/O # Pool.simulate_swap() previews swaps without state change # Pool.calculate_tokens_out_from_tokens_in() is pure math after construction
| Protocol | Versions | Chains |
|---|---|---|
| Uniswap | V2, V3, V4 | Ethereum, Base |
| Aerodrome | V2, V3 | Base |
| PancakeSwap | V2, V3 | Ethereum, Base |
| SushiSwap | V2, V3 | Ethereum, Base |
| Curve | V1 | Ethereum |
| Solidly | V2 | Ethereum, Base |
| Balancer | V2 | Ethereum |
| Camelot | V2 | Arbitrum |
| SwapBased | V2 | Base |
| Protocol | Features |
|---|---|
| Aave V3 | Supply, Borrow, Withdraw, Repay, Liquidation, E-Mode, GHO |
| Feature | Description |
|---|---|
| Chainlink Price Feeds | Oracle price data |
| Anvil Forking | Local forked blockchain for testing |
The following examples demonstrate the recommended Bot-based approach for pool and token construction.
All pool and token creation should flow through the Bot class for proper registry management and I/O handling:
import degenbot from degenbot.config import DegenbotConfig # Initialize Bot (handles config, connections, registries) bot = degenbot.Bot( config=DegenbotConfig( default_chain_id=1, rpc={1: RPC_URL}, database={"path": ":memory:"}, ) )
# Build tokens (fetches from DB/RPC, cached in registry) weth = bot.build_erc20token("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") usdc = bot.build_erc20token("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") # Build pools (fetches all state from DB/RPC, returns I/O-free pool objects) v3_pool = bot.build_pool("0x8ad599c3A0ff1De082011EFDDc58f1908EB6e6D8") v2_pool = bot.build_pool("0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc") curve_pool = bot.build_pool("0xbEbc44782C7db0a1A60Cb6fe97d0b483032FF1C7") # 3Crv # Universal builder -- auto-resolves pool type pool = bot.build_pool("0x8ad599c3A0ff1De082011EFDDc58f1908EB6e6D8") # V3, detected automatically # Token utilities with automatic caching balance = bot.get_token_balance(usdc, "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045") approval = bot.get_token_approval(usdc, owner="0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", spender="0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45") # Pools are I/O-free after construction - pure calculations amount_out = v3_pool.calculate_tokens_out_from_tokens_in( token_in=v3_pool.token0, token_in_quantity=1000_000000, # 1000 USDC )
V2 pools use the constant-product invariant (x·y=k) with directional fees:
# `lp` is the WBTC/WETH V2 pool; in production it comes from # `lp = bot.build_pool('0xBb2b8038a1640196FbE3e38816F3e67Cba72D940')`. # An off-line test fixture built the same pool here so the math below # runs without RPC. assert lp.token0.symbol == 'WBTC' assert lp.token1.symbol == 'WETH' assert lp.reserves_token0 == 10732489743 assert lp.reserves_token1 == 2056834999904002274711 # V2 directional fees (may differ per direction) assert lp.fee_token0 == Fraction(3, 1000) assert lp.fee_token1 == Fraction(3, 1000) # Calculate swap outputs - pure math, no I/O assert lp.calculate_tokens_out_from_tokens_in( token_in=lp.token1, token_in_quantity=1*10**18 ) == 5199789 assert lp.calculate_tokens_in_from_tokens_out( token_out=lp.token0, token_out_quantity=5199789 ) == 999999992817074189 # Pools are I/O-free: updates flow through external_update() # The builder (internal to Bot) fetches state and pushes updates update = UniswapV2PoolExternalUpdate( block_number=100, reserves_token0=10732455184, reserves_token1=2056841643098872755548, ) lp.external_update(update) # Reserves are updated in-place assert lp.reserves_token0 == 10732455184 assert lp.reserves_token1 == 2056841643098872755548
V3 pools use concentrated liquidity with tick-based positions. The V3 pool uses a sparse tick data fetcher for on-demand liquidity loading:
# `lp` is the WBTC/WETH 0.3% V3 pool; in production it comes from # `lp = bot.build_pool('0xCBCdF9626bC03E24f779434178A73a0B4bad62eD')`. # An off-line test fixture built the same pool here so the math below # runs without RPC. assert lp.token0.symbol == 'WBTC' assert lp.token1.symbol == 'WETH' assert lp.fee == 3000 assert lp.liquidity == 544425151051415575 assert lp.sqrt_price_x96 == 34048891009198980752047510166697902 assert lp.tick == 259432 # Calculate inputs and outputs - pure math, no I/O assert lp.calculate_tokens_out_from_tokens_in( token_in=lp.token1, token_in_quantity=1*10**18 ) == 5398169 # Tick bitmap and tick data are injected at construction assert 0 in lp.tick_bitmap assert 0 in lp.tick_data
V4 uses a singleton pool manager with hooks. Pools are identified by pool_id instead of address:
# `lp` is the ETH/USDC 0.05% V4 pool; in production it comes from # `lp = bot.build_managed_pool('<poolManager>', pool_id='0x<...>')` — V4 pools # are identified by (pool manager, pool id) rather than an address. # An off-line test fixture built the same pool here so the math below # runs without RPC. assert lp.token0.symbol == 'ETH' assert lp.token1.symbol == 'USDC' assert lp.liquidity == 60429069420043934 assert lp.sqrt_price_x96 == 4220772448119892035402666 assert lp.tick == -196812 # V4 features: hooks, protocol fees, dynamic LP fees assert lp.active_hooks == frozenset() assert lp.pool_key == UniswapV4PoolKey( currency0='0x0000000000000000000000000000000000000000', currency1='0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', fee=500, tick_spacing=10, hooks='0x0000000000000000000000000000000000000000', )
The AnvilFork class is used to launch a fork with anvil from the Foundry toolkit. The fork subprocess is spawned and driven by the Rust core; the Python AnvilFork is a thin companion over it. The object provides a provider attribute — an AlloyProvider — which can be used to communicate with the fork like any typical RPC client.
>>> fork = degenbot.AnvilFork(fork_url='http://localhost:8545') >>> fork.provider.chain_id 1 >>> fork.provider.block_number 22675736 # The `AnvilFork` instance also exposes HTTP and WS endpoints that can be used to make a # separate connection from a remote machine. >>> from degenbot.provider import AlloyProvider >>> _prov = AlloyProvider(fork.http_url) >>> _prov.is_connected() True # The fork can be reset to a specific block (defaults to the latest block). >>> fork.reset(block_number=22_675_800) >>> fork.provider.block_number 22675800 # A different endpoint or start block needs a NEW fork — `reset` cannot retarget # the fork URL. An "imaginary" block after a historical transaction (anvil # `--fork-transaction-hash`, see the [Anvil reference](https://getfoundry.sh/anvil/reference/)) # is a constructor option: >>> fork = degenbot.AnvilFork( fork_url='http://localhost:8545', fork_transaction_hash='0xc16e63e693a2748559c0fd653ade195be426472dddc5bfa3fcc769c4c88c249c', ) # Blocks can be manually mined >>> fork.mine() # Byte code can be set for an arbitrary address. >>> fork.set_code( address='0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', code=bytes.fromhex('45') ) >>> fork.provider.get_code('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045') b'\x45'
The Anvil client offers many options; the most common ones are exposed as AnvilFork constructor options. For fine-grained control, pass any raw anvil flag through the anvil_opts argument (a list of strings, e.g. anvil_opts=['--optimism'] or anvil_opts=['--hardfork=london']) — they are appended after all managed options.
Curve pools follow the same I/O-free architecture: the Bot resolves metapool detection, lending-token identification, and all on-chain inputs before the pool runs pure math:
# `tripool` is Curve's 3Crv pool; in production it comes from # `tripool = bot.build_pool('0xbEbc44782C7db0a1A60Cb6fe97d0b483032FF1C7')`. # An off-line test fixture built the same pool here so the math below # runs without RPC. assert [t.symbol for t in tripool.tokens] == ['DAI', 'USDC', 'USDT'] assert tripool.a_coefficient == 2000 assert tripool.fee == 4000000 # For lending pools (cTokens), rates are resolved before calculation; # get_dy() resolves all on-chain inputs upfront, then computes with pure math
Balancer V2 weighted pools use the weighted product invariant with configurable token weights and a singleton Vault architecture. The math libraries are ported from the Balancer V2 Solidity monorepo with exact integer-level matching against on-chain results.
from degenbot.balancer.pools import BalancerV2Pool # `weighted_pool` is the real mainnet "80 BAL 20 WETH" pool, built off-line by # the fixture above so the math below runs without RPC. In production the same # object comes from: # weighted_pool = bot.build_pool('0x5c6Ee304399DBdB9C8Ef030aB642B10820DB8F56') assert isinstance(weighted_pool, BalancerV2Pool) assert weighted_pool.address == '0x5c6Ee304399DBdB9C8Ef030aB642B10820DB8F56' assert weighted_pool.vault == '0xBA12222222228d8Ba445958a75a0704d566BF2C8' assert [t.symbol for t in weighted_pool.tokens] == ['BAL', 'WETH'] assert weighted_pool.fee == Fraction(1, 100) # 1% swap fee assert weighted_pool.weights == (8 * 10**17, 2 * 10**17) # 80 BAL / 20 WETH # Swap math is pure after construction — no I/O amount_out = weighted_pool.calculate_tokens_out_from_tokens_in( token_in=weighted_pool.tokens[1], # WETH in token_out=weighted_pool.tokens[0], # BAL out token_in_quantity=10**18, ) assert amount_out == 61874980427000000 # ≈ 0.0619 BAL per WETH at 80/20 + 1% fee amount_in = weighted_pool.calculate_tokens_in_from_tokens_out( token_in=weighted_pool.tokens[1], # WETH in token_out=weighted_pool.tokens[0], # BAL out token_out_quantity=100 * 10**18, ) assert amount_in == 1616565737428323232324
Contract addresses and broken pool filters are centralized in degenbot.balancer.deployments:
from degenbot.balancer.deployments import ( BALANCER_V2_VAULT_ADDRESS, BALANCERQUERIES_CONTRACT_ADDRESS, BROKEN_BALANCER_V2_POOLS, ) # Canonical Vault + BalancerQueries addresses assert BALANCER_V2_VAULT_ADDRESS == '0xBA12222222228d8Ba445958a75a0704d566BF2C8' assert BALANCERQUERIES_CONTRACT_ADDRESS == '0xE39B5e3B6D74016b2F6A9673D7d7493B6DF549d5' # BROKEN_BALANCER_V2_POOLS is a frozenset of pools with swaps disabled on-chain. # Filter before constructing: broken = '0x753BD6a5bF0b14ae7e5d2877e5cD6a3398aA2AAB' # YUME/WETH 1/99 assert broken in BROKEN_BALANCER_V2_POOLS assert weighted_pool.address not in BROKEN_BALANCER_V2_POOLS # 80 BAL 20 WETH is healthy
Balancer V2 stable pools (MetaStablePool and ComposableStablePool) use the StableSwap invariant with rate caching. The math libraries are ported from deployed contracts with exact integer-level matching against on-chain results.
Two pool shapes share the same BalancerV2StablePool interface:
- MetaStablePool — a 2-token stable pool with no BPT token and near-static rates: exact swap math needs no rate provider and no extra I/O.
- ComposableStablePool — a multi-token stable pool that includes its own BPT token; time-varying rates (e.g., bb-a-* yield tokens) require a live
BalancerRateProvider, and without one a swap call raisesStaleRateResult(the approximate result is still readable on the exception).
from degenbot.balancer.stable_pools import BalancerV2StablePool from degenbot.exceptions.pool import StaleRateResult # Both pools were built off-line by the fixture above so the math runs # without RPC; production obtains the same objects via `bot.build_pool(address)`. # MetaStablePool: 2-token, no BPT, near-static rates — exact swap math # needs no rate provider and no live RPC. assert isinstance(meta_pool, BalancerV2StablePool) assert [t.symbol for t in meta_pool.tokens] == ['wstETH', 'WETH'] assert meta_pool.fee == Fraction(4, 10000) # 0.04% assert meta_pool.amp == 50_000 amount_out = meta_pool.calculate_tokens_out_from_tokens_in( token_in=meta_pool.tokens[1], # WETH in token_out=meta_pool.tokens[0], # wstETH out token_in_quantity=10**18, ) assert amount_out == 908727110808623404 # ≈ 0.9087 wstETH per WETH @ 1.1 rate # ComposableStablePool: time-varying rates require a live rate provider; # without one the call raises StaleRateResult (the approximate result is # still readable on the exception). assert isinstance(comp_pool, BalancerV2StablePool) assert [t.symbol for t in comp_pool.tokens] == ['TUSD', '50TUSD50USDC', 'USDC'] assert comp_pool.fee == Fraction(3, 10000) # 0.03% try: comp_pool.calculate_tokens_out_from_tokens_in( token_in=comp_pool.tokens[0], # TUSD in token_out=comp_pool.tokens[2], # USDC out token_in_quantity=10**18, ) except StaleRateResult as e: # StaleRateResult wraps the approximate result so callers can still read it assert e.amount_in == 10**18 assert e.amount_out == 1001103
Optimal arbitrage amounts for a cyclic pool sequence are computed by the Rust ArbitrageEngine (EVM-exact U512 solve), driven through EngineRegistry. The older Python cycle/solver classes are retired — the Rust engine is the sole solve surface:
from degenbot.arbitrage.engine_registry import EngineRegistry # EngineRegistry is the one canonical entry point: it runs the pre-pump # startup ritual (subscribe -> backfill from snapshot -> verify config) and # registers cyclic paths against a Bot's shared BotState. The Rust engine # owns the EVM-exact U512 solve and re-solves affected paths on each block. registry = EngineRegistry(bot=bot)
# In production, `registry.start(node_http, node_ws)` runs the startup # ritual (subscribe, snapshot, verify config) and returns BEFORE resume(); # after attaching the result consumer, `registry.engine.resume()` is the # single gate after which one result batch per block flows. path_id = registry.register_path( pools_and_zfos=[(v2_pool, True), (v3_pool, False)], ) # The registered path is inspectable immediately (a solved snapshot of its # hops); profitable solves surface in the next `latest_results()` batch. solved_path = registry.engine.inspect_path(path_id) assert solved_path["path_id"] == path_id
A profitable solve goes straight from the Rust engine to a submitted transaction — there is no Python encoding layer. After a path solves:
- Encode — the Rust core emits the per-hop calldata (V2
swap(), V3swap(), V4 PoolManagerswap(), Curveexchange()/exchange_underlying()) and composes it into the cmd-executor contract envelope. - Submit — the Rust submission layer signs (EIP-1559) and sends the transaction; dry-run mode (the default) stops after solving and simulates in-process without submitting.
The end-to-end settlement-arbitrage bot — the flagship Rust-core-driven workload — is a thin Python driver over the degenbot.runner package: the example owns only CLI parsing + SIGINT handling, while BotRunner owns the session and the Rust engine handshake.
# Dry run (default): solves, simulates in-process, renders profit lines — nothing is submitted uv run python examples/eth_settlement_arbitrage_v2_v3_v4_rust.py \\ --node-http "https://eth-mainnet.example.com" \\ --node-ws "wss://eth-mainnet.example.com/ws" # Restrict to one 3-hop permutation (overrides the driver's default path filter) uv run python examples/eth_settlement_arbitrage_v2_v3_v4_rust.py --permutation V2-V3-V4 # Live mode: signs and submits real transactions (operator key via env) uv run python examples/eth_settlement_arbitrage_v2_v3_v4_rust.py --live
Endpoints and operator keys come from examples/mainnet.env + the OS env: DEGENBOT_RPC_HTTP_CHAINID_1 / DEGENBOT_RPC_WS_CHAINID_1 (CLI --node-http / --node-ws take precedence), OPERATOR_ADDRESS / OPERATOR_PRIVATE_KEY in live mode, and optional EXECUTOR_CONTRACT_ADDRESS overrides. BotRunner performs the driver-side startup handshake, after which the Rust core owns the hot loop — event decode, per-block re-solve, in-process simulation, encoding, submission — and the Python driver owns config, result rendering, and dispatch policy. With --operator-socket PATH, the bot also hosts an OperatorServer that the degenbot path add / degenbot path discover CLI commands target to steer the live path set without a restart (protocol + design in docs/architecture/operator-add-path-surface.md).
The Bot class is the primary entry point for degenbot usage. Access factories, registries, and utilities through Bot.
import degenbot from degenbot.config import DegenbotConfig # With explicit config bot = degenbot.Bot( config=DegenbotConfig( default_chain_id=1, rpc={ 1: RPC_URL, }, database={"path": "~/.config/degenbot/degenbot.db"}, ) ) # The RPC provider is built from the config and its eth_chainId is enforced # to equal default_chain_id at construction — no manual registration needed.
# Universal builder — auto-resolves pool type from DB, registry, or on-chain probing pool = bot.build_pool( "0x8ad599c3A0ff1De082011EFDDc58f1908EB6e6D8", state_block=18900000, # Optional, defaults to current block )
# For V4 pools, use build_managed_pool with the PoolManager address + pool_id pool = bot.build_managed_pool( "0x...", # PoolManager address pool_id="0x...", )
# V2 pool (auto-detected from factory) pool = bot.build_pool( "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc", state_block=18900000, # Optional, defaults to current block ) # V3 pool (auto-detected from factory) pool = bot.build_pool( "0x8ad599c3A0ff1De082011EFDDc58f1908EB6e6D8", ) # Curve pool (auto-detected from on-chain probing) pool = bot.build_pool( "0xbEbc44782C7db0a1A60Cb6fe97d0b483032FF1C7", )
# V4 pool (singleton architecture with pool_id) pool = bot.build_managed_pool( "0x...", # PoolManager address pool_id="0x...", state_view_address="0x...", tokens=["0x...", "0x..."], fee=500, tick_spacing=10, )
# ERC-20 token (fetches name, symbol, decimals from DB/RPC) token = bot.build_erc20token("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") # Token lookup (from registry if cache hit) token = bot.get_token("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")
# Get balance at block (cached per-bot) balance = bot.get_token_balance(token, "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045") balance_at_block = bot.get_token_balance(token, "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", block_identifier=10000000) # Get approval amount (cached) approval = bot.get_token_approval(token, owner="0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", spender="0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45") # Get total supply (cached) total_supply = bot.get_token_total_supply(token) # Get native ETH balance eth_balance = bot.get_ether_balance(address="0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")
# RPC provider (built from config; chain_id enforced at construction) provider = bot.provider # Registries (check if already created) existing_pool = bot.pools.get(chain_id=1, pool_address="0x8ad599c3A0ff1De082011EFDDc58f1908EB6e6D8") existing_token = bot.tokens.get(token_address="0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", chain_id=1) # Database session with bot.db() as session: # SQLAlchemy operations pass
Chainlink price feeds provide reliable oracle data for various assets. The ChainlinkPriceContract class simplifies access to these feeds.
# Load the price feed for ETH/USD # decimals can be provided to avoid a live RPC call assert price_feed.address == '0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419' assert price_feed.decimals == 8 # price_feed.price requires a Bot instance with RPC access for live data
Degenbot provides a command-line interface for managing blockchain data and pool state.
The CLI is installed automatically with the package:
pip install degenbot degenbot --help
# Back up the database degenbot database backup # Reset database (creates fresh schema; hidden command, --force skips the prompt) degenbot database reset --force # Upgrade database schema to the latest Alembic revision degenbot database upgrade [--force] # Compact database to reclaim space degenbot database compact # Inspect schema ownership (Alembic vs Rust) + preview the cutover degenbot database cutover --dry-run # One-way cutover from Alembic to Rust schema ownership (ADR-010) degenbot database cutover [--force] # Out-of-place heal: rebuild a stale Alembic DB at the Rust head schema (ADR-011) degenbot database heal [--dry-run]
# Update pool metadata and liquidity positions for all active exchanges # (--verify-chunk/--verify-all add pre-commit on-chain-truth gates: a # divergence rolls the chunk back and does NOT advance last_update_block) degenbot pool update [--chunk SIZE] [--to-block BLOCK] [--verify-chunk/--no-verify-chunk] [--verify-all/--no-verify-all] # Verify one V3/V4 pool's DB state against on-chain truth at a given block degenbot pool verify --rpc-url URL --chain 1 --block 18900000 --pool 0x... --family v3|v4 [--pool-manager 0x...] # Activate an exchange for tracking degenbot exchange activate base_uniswap_v3 # Deactivate an exchange degenbot exchange deactivate base_uniswap_v3 # Steer a running bot (started with --operator-socket) without restarting it degenbot path add --socket /path/to/operator.sock --hop V2:0xPoolAddr [--hop V3:0xPoolAddr] [--direction zfo|ozf] degenbot path discover --socket /path/to/operator.sock [--bound N]
Supported exchanges:
- Base:
base_aerodrome_v2,base_aerodrome_v3,base_pancakeswap_v2,base_pancakeswap_v3,base_sushiswap_v2,base_sushiswap_v3,base_swapbased_v2,base_uniswap_v2,base_uniswap_v3,base_uniswap_v4 - Ethereum:
ethereum_pancakeswap_v2,ethereum_pancakeswap_v3,ethereum_sushiswap_v2,ethereum_sushiswap_v3,ethereum_uniswap_v2,ethereum_uniswap_v3,ethereum_uniswap_v4
# Update Aave V3 positions for all active markets degenbot aave update [--chunk SIZE] [--to-block BLOCK] [--verify-chunk/--no-verify-chunk] [--dry-run] # Activate an Aave market degenbot aave activate ethereum_aave_v3 # Deactivate an Aave market degenbot aave deactivate ethereum_aave_v3 # Show a user's position in a market degenbot aave position show <ADDRESS> [--market MARKET] [--chain-id CHAIN_ID] # Scan all users in a market for liquidation risk (market-wide; no single address) degenbot aave position risk [--market MARKET] [--chain-id CHAIN_ID] [--threshold 1.1] [--limit N] [--show-positions] # Show market state degenbot aave market show [--chain-id CHAIN_ID] [--name NAME]
Commands accepting --to-block support the following formats:
| Format | Example | Description |
|---|---|---|
latest |
latest |
Latest block |
latest:-N |
latest:-64 |
N blocks before latest (default) |
safe:+N |
safe:128 |
N blocks after safe block |
| Number | 18900000 |
Specific block number |
| Variable | Values | Description |
|---|---|---|
DEGENBOT_DEBUG |
1, true, yes |
Enable debug-level logging output |
DEGENBOT_DEBUG_FUNCTION_CALLS |
1, true, yes |
Enable function call trace logging |
DEGENBOT_RPC_HTTP_CHAINID_<ID> |
any HTTP(S) URL | HTTP RPC endpoint for chain <ID>; overrides config.toml [rpc] |
DEGENBOT_RPC_WS_CHAINID_<ID> |
any WS(S) URL | WebSocket endpoint for chain <ID>; overrides config.toml [ws] |
DEGENBOT_DEBUG=1 python my_script.py
Degenbot uses a TOML configuration file located at ~/.config/degenbot/config.toml:
# The chain this Bot session targets (required). One Bot per chain — see ADR-006. # Must match a chain ID key in [rpc]; the connected RPC's eth_chainId is # enforced to match at construction (fail-fast) default_chain_id = 1 [rpc] # Chain ID to HTTP RPC endpoint mapping 1 = "https://eth-mainnet.example.com" 8453 = "https://base-mainnet.example.com" [ws] # Chain ID to WebSocket endpoint mapping — the settlement-arbitrage pump # subscribes to newHeads over WS, so a bot run needs this 1 = "wss://eth-mainnet.example.com/ws" 8453 = "wss://base-mainnet.example.com/ws" [database] # SQLite database path (optional, defaults to platform-specific location) path = "/path/to/degenbot.db"
default_chain_id (required) selects the single chain this Bot targets — a
Bot refuses to construct without it, and the connected RPC's eth_chainId
is enforced to match it at construction. (Per-chain RPC/WS endpoints can also
be supplied via the DEGENBOT_RPC_{HTTP,WS}_CHAINID_<ID> env vars above — the
cascade is CLI flags > OS env > config.toml.) A [deployments] table may
additionally carry a user overlay on the shipped pool-type/deployment
registry (src/degenbot/registry/deployments.json is the single source of
truth).
The Rust core is the engine of degenbot — it owns all performance-critical and stateful logic. Python reaches it through the degenbot._ffi extension module, a thin PyO3 binding layer (rust/crates/degenbot-python/) that translates Python calls into Rust calls with no business logic of its own. The underlying core crates are pyo3-free by default and are consumable directly from pure Rust through the umbrella degenbot crate — currently via a git/path dependency (the workspace sets publish = false); the in-repo proof is rust/crates/degenbot/examples/standalone_consumer.rs, gated by just test-standalone.
The extension is built automatically during installation using maturin (or uv sync, which invokes maturin under the hood).
Uniswap V3 tick-to-price conversions (Q96 fixed point):
from degenbot.uniswap.v3_libraries import get_sqrt_ratio_at_tick, get_tick_at_sqrt_ratio # Convert tick to sqrt price (Q96) sqrt_price = get_sqrt_ratio_at_tick(253320) assert sqrt_price == 25082941840919119221697001330704483 # Convert the full sqrt price back into the tick — exact round-trip assert get_tick_at_sqrt_ratio(sqrt_price) == 253320
High-performance ABI decoding for contract data:
from degenbot._ffi.abi import decode, decode_single, encode # Encode then decode multiple values types = ["address", "uint256", "uint256"] data = encode(types, ["0x0000000000000000000000000000000000000001", 100, 200]) values = decode(types, data) # Returns list of decoded values assert values == ["0x0000000000000000000000000000000000000001", 100, 200] # Decode a single value address = decode_single("address", data[:32]) assert address == "0x0000000000000000000000000000000000000001"
EIP-55 checksummed address conversion:
from degenbot import get_checksum_address checksummed = get_checksum_address("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") assert checksummed == "0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"
Encode function calls and compute selectors:
from degenbot.contract import encode_function_call, get_function_selector, decode_return_data
# Get a 4-byte function selector selector = get_function_selector("transfer(address,uint256)") assert selector == "0xa9059cbb" # Encode a function call (selector + encoded args) calldata = encode_function_call( "transfer(address,uint256)", ["0x0000000000000000000000000000000000000001", "100"], ) assert calldata[:4].hex() == "a9059cbb" # Decode the same calldata body back out values = decode_return_data(calldata[4:], ["address", "uint256"]) assert values == ["0x0000000000000000000000000000000000000001", "100"]
bot.provider is the normal way to reach chain data. The extension also exposes the raw synchronous/async RPC provider classes directly for the cases where the Bot conveniences don't fit:
# Create provider with connection pooling provider = AlloyProvider(RPC_URL) # Contract interaction contract = Contract( "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", provider_url=RPC_URL, ) # Query blockchain block_number = provider.get_block_number() chain_id = provider.get_chain_id() logs = provider.get_logs( from_block=block_number - 10, to_block=block_number, addresses=["0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"], ) result = contract.call( "balanceOf(address)", ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"], block_number, ) provider.close()
The extension also includes async wrappers for use with asyncio:
from degenbot._ffi.contract import AsyncContract from degenbot._ffi.provider import AsyncAlloyProvider # Create an async provider async_provider = await AsyncAlloyProvider.create( rpc_url="https://eth-mainnet.example.com", max_retries=10, max_blocks_per_request=5000, ) # Async contract interaction (built via `create`; `from_provider` wraps an existing provider) async_contract = await AsyncContract.create("0x...", provider_url="https://...") result = await async_contract.call("balanceOf(address)", ["0x..."]) # Batch multiple contract calls results = await async_contract.batch_call( [("balanceOf(address)", ["0x..."]), ("totalSupply()", [])], )
from degenbot._ffi.provider import LogFilter # Build a log filter log_filter = LogFilter( from_block=1000000, to_block=1000100, addresses=["0x0000000000000000000000000000000000000001"], topics=[["0x0000000000000000000000000000000000000000000000000000000000000001"]], )
AlloyProvider also exposes pub-sub — subscribe_blocks(), subscribe_logs(...), subscribe_pending_transactions(), and friends return an async-iterable AlloySubscription (the primitive the settlement-arbitrage pump consumes) — plus offline modes (AlloyProvider.offline_from_json_file(path) / offline_from_json_string(s)) that answer from recorded RPC fixtures for deterministic tests, and opt-in transport-level rate limiting (requests_per_second + burst constructor args).
Advanced drivers can also reach the engine directly through the degenbot._ffi module — the settlement-arbitrage engine (ArbitrageEngine), the shared Bot state handle, plus the I/O, submission, and price seams — instead of going through the degenbot.* conveniences. Its type stubs (src/degenbot/_ffi/*.pyi) are the reference surface.
The MEV workload — per-block re-solve of hundreds of cyclic paths, EVM-exact revm simulation, ABI decode, tick math, and swap encoding — is latency-bound at the CPython boundary, so the pump loop runs in Rust with the GIL released around each PyO3 crossing. Per-operation microbenchmarks are not tracked in-repo.
The extension is pre-built in published packages. For source builds:
- A recent stable Rust toolchain (CI tracks
@stable) - maturin (installed automatically with
uv sync)
# Build the extension (same as `just build-rust-extension`) cargo build -p degenbot_rs --features extension-module --manifest-path rust/Cargo.toml # Or use the justfile just dev # Build and install Python extension
Additional documentation is available in the docs/ directory:
- Architecture : High-level architectural patterns
- I/O-Free Pool Architecture — how pools keep I/O out of the calculation path
- Rust-Owned Settlement-Arbitrage Bot — the original
ArbitrageEnginedesign (Plans 079–082); marked historical, kept as a design-history reference (the current state layer follows the ADR log) - Operator Add-Path Surface — steering a live bot (mid-run add-path + bounded on-demand discovery) over the Unix-socket JSON-lines operator channel
- Semantic Matching — Event processing patterns for Aave
- Architecture Decision Records : the 34-ADR design log for the Python→Rust migration (three-layer architecture, per-chain Bot, schema retention/cutover, registration-verify lifecycle, executor grammar, ...)
- Execution Strategy : the user-owned
ExecutionStrategyseam (ADR-025) - Aave V3 : Comprehensive control flow diagrams and amount transformations for Aave operations
- CLI : Detailed CLI command reference (
aave.md,database.md,pool.md) - Logging : Controlling
RUST_LOG/DEGENBOT_DEBUGtracing, the env-gated hard/loud diagnostics, and debug-named diagnostics
Verified Solidity source code for all supported protocols is in contract_reference/:
| Protocol | Path | Contents |
|---|---|---|
| Uniswap V2 | contract_reference/uniswap/V2/ |
Factory, Pair, ERC20, SafeMath, Math, UQ112x112 |
| Uniswap V3 | contract_reference/uniswap/V3/ |
Factory, Pool, Oracle, Tick, TickBitmap, SqrtPriceMath, SwapMath, TickMath, FullMath, Position, etc. |
| Uniswap V4 | contract_reference/uniswap/V4/ |
PoolManager, Pool, Hooks, TickBitmap, SqrtPriceMath, SwapMath, ProtocolFeeLibrary, LPFeeLibrary, ERC6909, etc. |
| Aave V3 | contract_reference/aave/ |
Pool (10 revisions), AToken (5 revisions), VariableDebtToken, GhoVariableDebtToken (6 revisions), GhoDiscountRateStrategy, AaveOracle, stkAAVE, RewardsController |
Useful when auditing, or when you need to understand the exact on-chain behavior of a supported protocol. See contract_reference/README.md for the full index.
Contributions are welcome! Please submit issues and pull requests to the GitHub repository.
git clone https://github.com/BowTiedDevil/degenbot.git cd degenbot uv sync # Run the full gate: standalone-Rust smoke + cargo workspace + full pytest just test # Individual tracks: just test-rust # cargo workspace + just test-standalone just test-python # uv run pytest
This code is published under a permissive MIT license. See LICENSE for details.
If you find this code valuable, please fund continuing development by donating to 0xADAf500b965545C8A766CD9Cdeb3BF3FBef073e5 on any EVM compatible chain.