Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4,794 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Degenbot

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 botcargo add degenbot (the umbrella crate re-exporting the cores) 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 immutable config dual-tracking. See AGENTS.md and docs/adr/ADR-005-polars-inspired-three-layer-architecture.md for the full architectural vision.

Contents

Overview

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.

Today the Python layer still owns some infrastructure (database via SQLAlchemy, RPC via web3.py, publisher/subscriber, price oracles, the DB-aware updaters, simulation, and transaction submission); each of these is on the migration path into the Rust core, moved one piece at a time.

These classes serve as building blocks for the lessons published by BowTiedDevil on Degen Code.

Architecture: The Python-Rust Split

Degenbot follows a Polars-inspired three-layer architecture (ADR-005). Every concern belongs to exactly one layer:

Layer Where it lives Holds
Rust core rust/crates/degenbot-{core,-cl-math,-curve-math,-balancer-math,-abi,-decoders,-uniswap,-rpc,-bot,-pools,-solvers,…}zero pyo3 by default data + state-machine logic + pure math + protocols (DexIdentity, encoders, decoders)
PyO3 wrapper rust/crates/degenbot-python/src/<domain>/** (the degenbot._ffi extension module) #[pyclass]/#[pyfunction] only — arg extraction → GIL release → core call → result wrap. No business logic.
Python companion src/degenbot/** user-facing API, docstrings, I/O orchestration, immutable config dual-tracking, Fraction-based display

The standalone-Rust-core constraint: anything a standalone Rust consumer (cargo add degenbot) needs to build an MEV bot lives in a core crate from day one — never stranded on the Python side. The no-pyo3-in-cores invariant is enforced by just check-no-pyo3-in-cores.

Rust Core Crates

The Rust workspace under rust/crates/ exposes focused, independently consumable crates:

Crate Responsibility
degenbot-core Shared types, DexIdentity, protocols
degenbot-v2-math / degenbot-cl-math / degenbot-curve-math / degenbot-balancer-math / degenbot-solidly-math / degenbot-evm-math Per-protocol pure swap/invariant math
degenbot-pools I/O-free pool state machines
degenbot-decoders / degenbot-abi Event + ABI decode/encode
degenbot-uniswap Uniswap V2/V3/V4 domain types
degenbot-rpc / degenbot-fork RPC interaction + anvil forking
degenbot-solvers / degenbot-pathfinding Arbitrage solving + path discovery
degenbot-bot The Bot state owner + pump/engine lifecycle
degenbot-db Rust-owned schema (cutover target, ADR-010)
degenbot-pool-updater / degenbot-aave-updater DB-aware state updaters
degenbot-price / degenbot-executor / degenbot-submission / degenbot-simulation Oracles, executor, tx submission, simulation
degenbot-python The PyO3 binding layer (degenbot._ffi Python module, degenbot_rs Rust crate)
degenbot Umbrella crate re-exporting the cores for cargo add degenbot

Full component map and pump/engine lifecycle in docs/architecture/rust-owned-bot.md.

Installation

Requirements

  • Python 3.12+
  • pip, uv, or similar package management tool

From PyPI

pip install degenbot

From Source

git clone https://github.com/BowTiedDevil/degenbot.git
cd degenbot
uv sync  # or: pip install -e .

Quick Start

Modern Bot-Based Approach (Recommended)

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}")

Direct Pool Construction (Advanced)

Pool classes cannot be constructed from an address alone — all state must be provided as keyword arguments. Use Bot.build_pool() instead:

# Do NOT do this — will raise AttributeError:
# pool = degenbot.UniswapV3Pool("0x...")  ← BROKEN!

# Instead, always use Bot to construct pools:
pool = bot.build_pool("0x8ad599c3A0ff1De082011EFDDc58f1908EB6e6D8")

Core Concepts

I/O-Free Architecture

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. Construction is handled by typed Builder classes (V2PoolBuilder, V3PoolBuilder, V4PoolBuilder, CurvePoolBuilder, Erc20Builder) that own the full I/O choreography: DB lookup → RPC fetch → decode → construct pool → register.

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 (Curve, V2, V3, V4, Aerodrome, Camelot) are fully I/O-free — no pool class imports ProviderAdapter or carries provider-dependent methods. Construction and updates flow through builders, and all state changes enter pools via external_update(). Builder update() methods are @staticmethod — all I/O flows through the io parameter, enforced by the PoolBuilder/AsyncPoolBuilder protocol type signatures. Curve calculators receive a DyCalculationInputs frozen dataclass carrying pre-resolved data, eliminating all private member access (no pool._xxx patterns).

The Bot Class

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  # ProviderAdapter for chain 1
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 session
  • bot.provider - ProviderAdapter for the chain (chain_id enforced at construction)
  • bot.pools - PoolRegistry for created pools
  • bot.tokens - TokenRegistry for created tokens
  • bot.managed_pools - ManagedPoolRegistry for V4 pools
  • bot.db - DatabaseSessionManager for state snapshots

Builders are internal to Bot and not exposed publicly. All pool/token creation goes through Bot.build_pool().

Pool Types and Builders

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, type resolution proceeds in order: (1) pool registry for existing pools, (2) database kind column, (3) pool type registry mapping (chain_id, factory_address) → pool class, (4) on-chain probing via slot0(), getReserves(), or coins().

External Updates

Pools receive state updates via external_update() — a pure-logic method that validates the update and transitions pool state. The builder handles all I/O (fetching reserves, slot0, etc. from RPC), constructs an 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

Supported Protocols

DEXs (Automated Market Makers)

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

Lending Protocols

Protocol Features
Aave V3 Supply, Borrow, Withdraw, Repay, Liquidation, E-Mode, GHO

Infrastructure

Feature Description
Chainlink Price Feeds Oracle price data
Anvil Forking Local forked blockchain for testing

Examples

The following examples demonstrate the recommended Bot-based approach for pool and token construction.

Using the Bot Class (Recommended)

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
)

Direct Pool Construction (Advanced)

Pool classes cannot be constructed from an address alone — all state must be provided as keyword arguments. Use Bot.build_pool() instead:

# Do NOT do this — will raise AttributeError:
# pool = degenbot.UniswapV3Pool("0x...")  ← BROKEN!

# Instead, always use Bot to construct pools:
pool = bot.build_pool("0x8ad599c3A0ff1De082011EFDDc58f1908EB6e6D8")

Uniswap V2 Liquidity Pools

V2 pools use the constant-product invariant (x·y=k) with directional fees:

# Construct an I/O-free V2 pool (all state injected at construction)
# Tokens and reserves are provided directly — no RPC calls
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

Uniswap V3 Liquidity Pools

V3 pools use concentrated liquidity with tick-based positions. The V3 pool uses a sparse tick data fetcher for on-demand liquidity loading:

# Construct an I/O-free V3 pool (all state injected at construction)
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

Uniswap V4 Liquidity Pools

V4 uses a singleton pool manager with hooks. Pools are identified by pool_id instead of address:

# Construct an I/O-free V4 pool (all state injected at construction)
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',
)

Forking With Anvil

The AnvilFork class is used to launch a fork with anvil from the Foundry toolkit. The object provides a w3 attribute, connected to an IPC socket, which can be used to communicate with the fork like a typical RPC.

>>> fork = degenbot.AnvilFork(fork_url='http://localhost:8545')
>>> fork.w3.eth.chain_id
1
>>> fork.w3.eth.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.
>>> import web3
>>> _w3 = web3.Web3(web3.HTTPProvider(fork.http_url))
>>> _w3.is_connected()
True
>>> _w3 = web3.Web3(web3.LegacyWebSocketProvider(fork.ws_url))
>>> _w3.is_connected()
True

# The fork can be reset to a different endpoint, which defaults to the latest block.
>>> fork.reset(fork_url='http://localhost:8544')
>>> fork.w3.eth.chain_id
8453

# The fork can also be reset with a specified block number or a transaction hash.
>>> fork.reset(fork_url='http://localhost:8545', block_number=22_675_800)
>>> fork.w3.eth.chain_id
1
>>> fork.w3.eth.block_number
22675800

>>> fork.reset(fork_url='http://localhost:8545', block_number=22_675_800)
>>> fork.w3.eth.chain_id
1
>>> fork.w3.eth.block_number
22675800

# The fork can also be reset to an imaginary block after a specific transaction
# hash. See the [Anvil reference](https://getfoundry.sh/anvil/reference/) for the
# associated `--fork-transaction-hash` option.
>>> fork.reset(
    fork_url='http://localhost:8545',
    transaction_hash='0xc16e63e693a2748559c0fd653ade195be426472dddc5bfa3fcc769c4c88c249c'
)
>>> fork.w3.eth.block_number
22675814

# Blocks can be manually mined
>>> fork.mine()
>>> fork.w3.eth.block_number
22675815

# Byte code can be set for an arbitrary address.
>>> fork.set_code(
    address='0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
    bytecode=bytes.fromhex('45')
)
>>> fork.w3.eth.get_code('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045')
HexBytes('0x45')

Anvil Options

The Anvil client offers many options. The most common ones are exposed by constructor options to AnvilFork.

Users wanting fine-grained control over all client options may pass them through the anvil_opts argument, which takes a list of strings. These will be passed directly to the client after all of the managed options.

# Launch with the Optimism feature set, which enables special transaction types.
>>> fork = degenbot.AnvilFork(
    fork_url='http://localhost:8544',
    anvil_opts=['--optimism']
)

# Launch with a non-default hardfork, which may be necessary for accurate simulation on a
# historical block.
>>> fork = degenbot.AnvilFork(
    fork_url='http://localhost:8545',
    fork_block=12_980_000,
    anvil_opts=['--hardfork=london']
)

# Launch with a non-default transaction pool ordering scheme
>>> fork = degenbot.AnvilFork(
    fork_url='http://localhost:8545',
    anvil_opts=['--order=fifo']
)

# Launch with certain debugging features enabled
>>> fork = degenbot.AnvilFork(
    fork_url='http://localhost:8545',
    anvil_opts=[
        '--disable-block-gas-limit',
        '--disable-code-size-limit',
        '--disable-min-priority-fee',
    ]
)

Curve StableSwap Pools (I/O-Free)

Curve pools follow the I/O-free architecture with a single CurveDataProvider seam. The Bot handles metapool detection, lending token identification, and data provider injection:

# Construct an I/O-free Curve StableSwap pool
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
# Pool's get_dy() pre-resolves all I/O via CurveDataProvider, then passes
# pre-resolved data to calculators via DyCalculationInputs (pure math, no private access)

Balancer V2 Weighted Pools

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.

Key design points:

  • PowVersion detection: Different deployed pool contracts embed different versions of the FixedPoint library. WeightedPool2Tokens (V1) uses the general LogExpMath path with error bounds; WeightedPool (V2) includes fast paths for y == ONE/TWO/FOUR. The version is detected from bytecode at construction time.
  • Rounding direction: GIVEN_IN rounds down (seller gets less), GIVEN_OUT rounds up (buyer pays more).
  • Fee ordering: GIVEN_OUT applies downscale-up first, then add swap fee — matching Solidity's exact operation order.
  • Scaling: Tokens with non-18 decimals are normalized via scaling factors computed as ONE * 10**(18 - decimals).
from degenbot.balancer.pools import BalancerV2Pool, detect_pow_version
from degenbot.balancer.libraries.constants import PowVersion

# `detect_pow_version(bytecode)` resolves the PowVersion from deployed pool
# bytecode: V2 (WeightedPool) embeds the FixedPoint TWO fast-path constant
# (0x1bc16d674ec80000) for y == ONE/TWO/FOUR fast paths; V1 (WeightedPool2Tokens)
# omits it and uses the general LogExpMath path. `Bot.build_pool()` runs this
# internally; here it is exercised directly so the example stays I/O-free.
v1_bytecode = bytes(range(0, 32)).hex()          # arbitrary V1 bytecode (no TWO constant)
assert detect_pow_version(v1_bytecode) == PowVersion.V1
v2_bytecode = '1bc16d674ec80000' + '00' * 16     # V2 bytecode: TWO constant present
assert detect_pow_version(v2_bytecode) == PowVersion.V2

# `weighted_pool` was built offline by the fixture above; production code uses
# `bot.build_pool(address)` which runs the same registration against live RPC.
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
assert weighted_pool.pow_version == PowVersion.V1           # WeightedPool2Tokens

# GIVEN_IN rounds DOWN (seller gets less) — pure math, 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

# GIVEN_OUT rounds UP (buyer pays more): input needed for a target output
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
# (BAL#327 SWAPS_DISABLED). 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

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.

Key design points:

  • MetaStablePool: 2-token stable pool with rate providers. No BPT token. Uses V2 invariant (round_up=True for swaps). Near-static rates produce exact 0-wei matching without a rate provider.
  • ComposableStablePool: Multi-token stable pool including its own BPT token. Uses V1 invariant (always-roundDown). Time-varying rates (e.g., bb-a-* yield tokens) require a BalancerRateProvider for exact matching; without one, StaleRateResult is raised.
  • Cache-aware rate resolution: The CacheAwareRateProvider replicates the on-chain _cacheTokenRateIfNecessary flow exactly — reads getTokenRateCache(), checks cache expiry against block timestamp, and only calls getRate() when the cache has expired. This produces exact 0-wei matching against on-chain querySwap results.
  • Invariant versions: Deployed contracts use two different StableMath implementations. V1 (INVARIANT_V1) always rounds down with D_P accumulation — matches the monorepo _calculate_invariant. V2 (INVARIANT_V2) has a roundUp parameter with P_D accumulation — matches _calculate_invariant_deployed. Using the wrong version gives a systematic 1-wei error.
  • BPT handling: ComposableStablePools include their own BPT token in the token list. The bpt_idx parameter identifies the BPT position so it can be dropped from invariant and swap calculations. bpt_idx=None (MetaStable) vs bpt_idx=int (Composable).
from degenbot.balancer.stable_pools import BalancerV2StablePool, INVARIANT_V1, INVARIANT_V2
from degenbot.exceptions.pool import StaleRateResult

# MetaStablePool: V2 invariant, no BPT, near-static rates → no rate provider needed.
assert isinstance(meta_pool, BalancerV2StablePool)
assert [t.symbol for t in meta_pool.tokens] == ['wstETH', 'WETH']
assert meta_pool.bpt_idx is None                 # MetaStable has no BPT
assert meta_pool.invariant_version == INVARIANT_V2
assert meta_pool.fee == Fraction(4, 10000)        # 0.04%
assert meta_pool.amp == 50_000
# scaling factors embed the near-static wstETH:ETH rate (1.1)
assert meta_pool.scaling_factors == (11 * 10**17, 10**18)

# Exact 0-wei swap math — no rate provider, no live RPC
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: V1 invariant, BPT in the token list (dropped from
# invariant/swap math via bpt_idx), time-varying rates → a live rate provider
# is required for exact matching. Without one, swaps raise StaleRateResult.
assert isinstance(comp_pool, BalancerV2StablePool)
assert [t.symbol for t in comp_pool.tokens] == ['TUSD', '50TUSD50USDC', 'USDC']
assert comp_pool.bpt_idx == 1                      # BPT at index 1
assert comp_pool.invariant_version == INVARIANT_V1
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

# For exact 0-wei matching on a ComposableStablePool, construct with a live
# `BalancerRateProvider` (e.g. ``CacheAwareRateProvider``) and pass
# ``block_identifier`` so rates resolve at that block. With a *static* rate
# provider (or none at all, as here) the call raises regardless:
try:
    comp_pool.calculate_tokens_out_from_tokens_in(
        token_in=comp_pool.tokens[0],
        token_out=comp_pool.tokens[2],
        token_in_quantity=10**18,
        block_identifier=18_900_000,
    )
    raise AssertionError("expected StaleRateResult without a live rate provider")
except StaleRateResult:
    pass  # exact matching requires a non-static BalancerRateProvider

Uniswap Arbitrage

Optimal arbitrage amounts for a cyclic pool sequence are computed by the Rust ArbitrageEngine (EVM-exact U512 solve), driven through EngineRegistry — the production solve surface that replaced both the deprecated UniswapLpCycle / UniswapCurveCycle and the since-retired Python ArbitragePath wrapper (ACDWOC):

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)
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

# The Python `BrentSolver` reference oracle and the legacy
# `tests/arbitrage/test_engine_vs_brent_parity.py` cross-validation test
# were retired alongside the f64 hop-state taxonomy (ergo 6C32UV / LMM2NB);
# the Rust `ArbitrageEngine` is now the sole solve surface and its own
# regression corpus is the oracle.

Note: The legacy UniswapLpCycle / UniswapCurveCycle, the Python ArbitragePath wrapper, the Python BrentSolver reference oracle, and the Python SwapAmounts / generate_payloads encoding mirror have all been retired. The Rust ArbitrageEngine (driven via EngineRegistry) is the production solve surface, and on-chain calldata is produced Rust-side by degenbot_executor::composers::encode_cmd_stream / dispatch_profitable. There is no Python swap-amount encoding layer.

Swap Encoding & On-Chain Execution

On-chain calldata for a solved arb path is produced entirely in the Rust core. The Python SwapAmounts / generate_payloads / EncodedCall mirror was retired (epic 6Y2PBF) once dispatch_profitable became the sole encode/dispatch surface — there is no Python encoding pipeline to call.

The Rust encoding flow:

  1. ResolveEngineRegistry.register_path(...) builds a path_id against the BotState-owned pool identities.
  2. Solve — the Rust ArbitrageEngine produces optimal_input / hop_outputs / consumed_inputs for the registered path.
  3. Encodedegenbot_executor::composers::encode_cmd_stream emits the per-hop calldata (V2 swap(), V3 swap(), V4 PoolManager swap(), Curve exchange()/exchange_underlying()), composed into the bot's executor envelope (dispatch_profitable), with V4 BalanceDelta int128 overflow guarded Rust-side by composers::fits_int128.
  4. Submit — the resulting execute_calldata is handed to the submission layer (Rust-owned in the end state).

EngineRegistry and the example bot driver consume DispatchCandidate / PyDispatchOutcome (carrying path_info / hop_outputs) — never a Python SwapAmounts object.

Bot API Reference

The Bot class is the primary entry point for degenbot usage. Access factories, registries, and utilities through Bot.

Initialization

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 Pool Builder

# 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...",
)

Pool Construction by Type

# 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,
)

Token Factory

# 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")

Token Utilities (With Caching)

# 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")

Accessing Bot Components

# 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

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

CLI Reference

Degenbot provides a command-line interface for managing blockchain data and pool state.

Installation

The CLI is installed automatically with the package:

pip install degenbot
degenbot --help

Commands

Database Management

# Back up the database
degenbot database backup

# Reset database (creates fresh schema)
degenbot database reset

# Upgrade database schema to latest version
degenbot database upgrade [--force]

# Compact database to reclaim space
degenbot database compact

Pool State Management

# Update pool metadata and liquidity positions for all active exchanges
degenbot pool update [--chunk SIZE] [--to-block BLOCK]

# Activate an exchange for tracking
degenbot exchange activate base_uniswap_v3

# Deactivate an exchange
degenbot exchange deactivate base_uniswap_v3

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

Aave State Management

# Update Aave V3 positions for all active markets
degenbot aave update [--chunk SIZE] [--to-block BLOCK]

# 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]

# Show risk parameters for a user's position
degenbot aave position risk <ADDRESS> [--market MARKET] [--chain-id CHAIN_ID]

# Show market state
degenbot aave market show [--chain-id CHAIN_ID] [--name NAME]

Block Identifiers

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

Configuration

Environment Variables

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_DEBUG=1 python my_script.py

Configuration File

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 RPC endpoint mapping
1 = "https://eth-mainnet.example.com"
8453 = "https://base-mainnet.example.com"

[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.

The Rust Core (degenbot_rs Rust crate, degenbot._ffi Python module)

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 also consumable directly from pure Rust via cargo add degenbot.

The extension is built automatically during installation using maturin (or uv sync, which invokes maturin under the hood).

Key Dependencies

Crate Purpose
alloy Ethereum primitives (Address, U256, B256), RPC types, keccak256
pyo3 Python bindings with abi3-py312 for Python 3.12+ support
tokio Multi-threaded async runtime for concurrent RPC calls
parking_lot High-performance RwLock for thread-safe caching
thiserror Derivative error types
serde Serialization/deserialization
lru LRU cache implementation

Available Functions

Tick Math

Uniswap V3 tick-to-price conversions:

from degenbot.uniswap.v3_libraries import get_sqrt_ratio_at_tick, get_tick_at_sqrt_ratio

# Convert tick to sqrt price (X96 format)
sqrt_price = get_sqrt_ratio_at_tick(253320)  # Returns: 56736275128821120...

# Convert sqrt price back to tick
tick = get_tick_at_sqrt_ratio(56736275128821120)  # Returns: 253320

ABI Decoding

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

# Decode a single value
address = decode_single("address", data[:32])

Address Utilities

EIP-55 checksummed address conversion:

from degenbot import get_checksum_address

checksummed = get_checksum_address("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
# Returns: "0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"

ABI Encoding & Selectors

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"],
)

# Decode return data from a contract call
values = decode_return_data(calldata[4:], ["address", "uint256"])

Provider Classes

The extension includes synchronous and async Ethereum RPC providers:

# 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()

Async Provider

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_connections=10,
    timeout=30.0,
)

# Async contract interaction
async_contract = AsyncContract("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()", [])],
)

Log Filtering

from degenbot._ffi.provider import LogFilter

# Build a log filter
log_filter = LogFilter(
    from_block=1000000,
    to_block=1000100,
    addresses=["0x0000000000000000000000000000000000000001"],
    topics=[["0x0000000000000000000000000000000000000000000000000000000000000001"]],
)

Performance Benefits

Operation Pure Python Rust Extension
Tick math ~50μs ~0.1μs
ABI decode (10 values) ~200μs ~5μs
Address checksum ~10μs ~0.5μs
Log query (1000 logs) ~100ms ~20ms

Build Requirements

The extension is pre-built in published packages. For source builds:

  • Rust 1.70+ (stable toolchain)
  • maturin (installed automatically with uv sync)
# Build the extension
cargo build --release --features extension-module --manifest-path rust/Cargo.toml

# Or use the justfile
just dev  # Build and install Python extension

Documentation

Additional documentation is available in the docs/ directory:

  • Architecture: High-level architectural patterns
  • Aave V3: Comprehensive control flow diagrams and amount transformations for Aave operations
  • Arbitrage: Multi-pool cycle testing documentation
  • CLI: Detailed CLI command reference
  • Configuration: Configuration options

Contract Reference

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

These are the ground truth for matching on-chain behavior in Python. See contract_reference/README.md for the full index.

Contributing

Contributions are welcome! Please submit issues and pull requests to the GitHub repository.

Development Setup

git clone https://github.com/BowTiedDevil/degenbot.git
cd degenbot
uv sync

# Run tests
uv run pytest

License

This code is published under a permissive MIT license. See LICENSE for details.

Donation

If you find this code valuable, please fund continuing development by donating to 0xADAf500b965545C8A766CD9Cdeb3BF3FBef073e5 on any EVM compatible chain.

About

Python + Rust building blocks for Uniswap (V2, V3, V4), Curve V1, Solidly V2, Balancer V2 & Aave V3 arbitrage and liquidation bots on EVM-compatible blockchains

Topics

Resources

Stars

Watchers

Forks

Used by

Contributors

Languages