I/O-Free Pool ArchitectureΒΆ

OverviewΒΆ

The I/O-free architecture is a design pattern for pool implementations that decouples on-chain data fetching from pool logic. Instead of calling provider methods directly, pools receive a data provider (or fetcher callbacks, in earlier versions) at construction time and call them on-demand when data is needed.

MotivationΒΆ

The Problem with Direct I/OΒΆ

Traditional pool implementations embed I/O calls:

class OldStylePool:
    def __init__(self, address):
        self.address = address
        self.provider = get_connection_manager().get_provider(self.chain_id)
    
    def get_rate(self, block_number):
        # Direct I/O: pool knows about connections, providers, async handling
        return self.provider.call(self.rate_contract, "exchangeRateStored", block_number)

Problems:

  1. Testing is hard: Requires mocking providers, connection managers

  2. Async complexity: Pool must handle sync/async boundaries

  3. Coupling: Pool knows about infrastructure details (providers, chains, connections)

  4. State management: Provider references complicate pickling/serialization

The Fetcher Pattern SolutionΒΆ

class IoFreePool:
    def __init__(self, address, data_provider: CurveDataProvider | None = None):
        self.address = address
        self._data_provider = data_provider  # Injected seam
    
    def get_rate(self, block_number):
        # Pure delegation: pool doesn't know about providers
        return self._data_provider.lending_rate(block_number, token_address)

Benefits:

  1. Testability: Pass FakeCurveDataProvider with fixed return values; no mocking needed

  2. Clean separation: Pool logic is pure; I/O lives in data provider implementations

  3. Flexibility: Data providers can be sync or async; caller decides

  4. Serialization: Data provider is dropped on pickle, reconstructed on unpickle via builder

ArchitectureΒΆ

LayersΒΆ

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€----───-─-──┐
β”‚  Client Code (e.g., Arbitrage Cycle)        β”‚
β”‚  - Calls pool methods                       β”‚
β”‚  - Doesn't see data_provider calls          β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€----─────-──-─
β”‚  Pool Class (e.g., CurveStableswapPool)     β”‚
β”‚  - Pure swap calculation logic              β”‚
β”‚  - Calls data_provider on-demand            β”‚
β”‚  - No provider/connection imports           β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  CurveDataProvider Protocol                 β”‚
β”‚  - 13 methods: D, gamma, virtual_price,     β”‚
β”‚    base_virtual_price, price_scale,         β”‚
β”‚    admin_balances, lending_rate, etc.       β”‚
β”‚  - Single seam replaces 13 fetchers         β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€-──
β”‚  CurveDataProviderImpl (data_provider_impl) β”‚
β”‚  - Structured class with real methods       β”‚
β”‚  - Handles I/O, error handling, caching     β”‚
β”‚  - Takes ProviderAdapter directly           β”‚
└───────────────────────────────────────-----β”€β”˜

Protocol DefinitionsΒΆ

Curve uses a single CurveDataProvider protocol (structural subtyping), not ABCs:

@runtime_checkable
class CurveDataProvider(Protocol):
    """Single I/O seam for Curve pools."""

    def D(self, block_number: int) -> int: ...
    def gamma(self, block_number: int) -> int: ...
    def virtual_price(self, block_number: int) -> int: ...
    def base_virtual_price(self, block_number: int) -> int: ...
    def price_scale(self, block_number: int) -> tuple[int, ...]: ...
    def admin_balances(self, block_number: int) -> tuple[int, ...]: ...
    def lending_rate(self, block_number: int, token_address: str) -> int: ...
    def redemption_price(self, block_number: int) -> int: ...
    def block_timestamp(self, block_number: int) -> int: ...
    def block_number(self) -> int: ...
    def token_balance(self, block_number: int, token_address: str) -> int: ...
    def token_total_supply(self, block_number: int, token_address: str) -> int: ...
    def is_crypto(self) -> bool: ...

Why Protocols?

  • No inheritance required

  • Any object with matching methods works

  • Natural fit for _CurveDataProviderImpl wrapping existing fetcher closures

  • Tests use FakeCurveDataProvider with fixed return values

Data Provider Factory PatternΒΆ

The builder creates a _CurveDataProviderImpl via the fetcher factory:

def build(self, pool_address):
    # Create data provider (structured class, not closure factory)
    data_provider = CurveDataProviderImpl(
        provider=ProviderAdapter(...),
        pool_address=pool_address,
        ...,
    )
    
    # Inject single data_provider into pool
    return CurveStableswapPool(
        address=pool_address,
        data_provider=data_provider,  # Single parameter replaces 13 fetcher callbacks
        ...,
    )

Key insight: The pool never touches provider or connection_manager. I/O lives in the _CurveDataProviderImpl which wraps a ProviderAdapter.

Migration StatusΒΆ

CompletedΒΆ

  • Curve StableSwap Pools β€” fully I/O-free with CurveDataProvider seam (ADR-001 Phase 1–2, Plan 040 collapsed 13 fetchers β†’ 1 data provider)

  • All pool construction β€” Bot.build_*_pool() methods fetch data from DB/RPC and pass values to pool constructors; no provider references on pool objects after construction

  • Builder extraction β€” pool construction I/O has been extracted from Bot into typed builder classes (V2PoolBuilder, V3PoolBuilder, V4PoolBuilder, CurvePoolBuilder, Erc20Builder); V2 variant builders extracted into V2BuilderBase + AerodromeV2Builder (Plan 043); ADR-005 slice 7 step 4b folded the deleted CamelotBuilder’s on-chain fetches into V2PoolBuilder.build (Camelot branch keyed on dex.variant); V3/V4 builder base classes with shared pure-logic helpers and frozen dataclasses (Plan 060)

  • V2/V3/V4/Aerodrome pool classes β€” all ProviderAdapter-taking methods removed; I/O for construction and updates lives entirely in builders (ADR-001 Phase 3 complete, Plan 017)

  • Curve DyCalculator seam β€” 14 match/if dispatch branches in get_dy() replaced by injectable calculator objects; pure math functions in calculations/stableswap.py (Plan 039)

  • DyCalculationInputs β€” pool: CurveStableswapPool parameter in DyCalculator.calculate() replaced with inputs: DyCalculationInputs frozen dataclass carrying pre-resolved data; 77 SLF001 errors β†’ 0; calculators are pure consumers of pre-resolved data with no private member access (Plan 045). DyCalculationInputs is now a pure value object β€” all fields are ints, tuples, enums, or None (zero callables); calculators call stableswap_get_y() / stableswap_newton_y() directly with EVMRevertError wrapping (Plan 069).

  • Curve state mixin β€” 25 attributes + 22 properties with _xxx private pattern; StableswapPoolState (Plan 041)

  • ProviderBackend β€” merged EthereumProvider + _SyncProviderBackend β†’ ProviderBackend protocol; __getattr__ dispatch replaces 15Γ— delegation methods (Plan 042); EthereumProvider backward-compatibility alias removed (Plan 061); subscription stubs consolidated into SyncSubscriptionSupport/AsyncSubscriptionSupport mixins (Plan 058)

  • Builder Protocol β€” PoolBuilder protocol replaces the 4-way union type annotation; _dispatch_build() isinstance chain eliminated via **kwargs forwarding (Plan 035)

  • Pool β†’ Hop conversion β€” each pool’s to_hop_state() is the single source of truth; solver_hop_builders.py deleted (Plan 033)

  • SwapAmounts consolidation β€” input_amount()/output_amount() on AbstractSwapAmounts; build_swap_amount() on pool classes via ArbitragePathPool protocol; _extract_amount_in/out deleted (Plan 036)

  • Legacy arbitrage cycles β€” deleted; AbstractArbitrage and get_arbitrage_helpers() deleted (Plan 038). Full removal of the _legacy/ sub-package and UniswapLpCycle/UniswapCurveCycle redirects completes the migration to ArbitragePath + ArbSolver.

  • Bot typed builders deprecated β€” build_v2_pool, build_v3_pool, build_v4_pool, build_curve_pool emit DeprecationWarning; use build_pool() (Plan 044). Removed by Plan 059.

  • Functions module β€” functions.py split into domain-aligned modules: provider/call_helpers.py, provider/log_fetching.py, contract/addresses.py, contract/decoding.py (decode_address), calculations/ (EIP-1559 next_base_fee, since moved to the Rust core), provider/block_helpers.py; eip_191_hash deleted as dead code (Plan 037); decode_address moved from cli/aave_utils.py to contract/decoding.py (Plan 075)

  • CurveDataProviderImpl β€” 850-line closure bag (CurveFetcherFactory) replaced by structured CurveDataProviderImpl (~350 lines) with real methods and shared I/O helpers (Plan 049)

  • Curve per-block caches β€” 10 individual BoundedCache fields consolidated into CurveOnChainCache (Plan 054), absorbed into CurveStableswapPool as _cache_* fields with _get_cached_* accessors (Plan 068), then extracted into PerBlockCache class with mirror-free design (Plan 077). get_cached_virtual_price() resolves its own dependencies inline, eliminating the former side-effect mirrors (_base_cache_updated_value, _base_virtual_price_value).

  • Deprecated fetcher protocols β€” 8 deprecated *Fetcher protocol classes deleted from curve/types.py; superseded by CurveDataProvider (Plan 055)

  • Strategy enum factory methods β€” make_calculator() on SwapStyle, MetapoolRateStyle, MetapoolUnderlyingStyle; PoolStrategies auto-constructs calculators from enum values (Plan 056)

  • Calculation-time I/O β€” _build_calculation_inputs β†’ _resolve_calculation_inputs_via_io, requires_io_at_calculation_time property, ADR-001 amended with construction-time vs calculation-time I/O table (Plan 057)

  • Old optimizer hierarchy β€” ArbitrageOptimizer ABC, OptimizerResult/OptimizerType, and 7 concrete classes deleted (zero production callers); pure MΓΆbius math extracted to _mobius_math.py (Plan 053)

  • Rust extension GIL discipline & allocation reduction β€” removed py.detach() from sub-ΞΌs functions (tick math, address utils); two-level CachedAbiTypes intern (string Arc<str> interner + Arc<CachedAbiTypes> value return + Arc<[Arc<str>]> key); Arc-shared LogFilter fields; IntHopState pre-converted U512 fields; PyPoolCache with parking_lot::Mutex<LruCache> (10K cap) replacing unbounded HashMap; subscription drain_raw() for pure-Rust buffer mechanics; concurrency stress tests; f64_to_u256 4-limb decomposition fix; criterion benchmarks for ABI decode/encode and MΓΆbius solver (Plan 063)

  • DyCalculationInputs pure value object β€” get_y/newton_y closure fields removed; replaced with d_variant/y_variant/yd_variant/a_precision fields; calculators call stableswap_get_y()/stableswap_newton_y() directly with EVMRevertError wrapping; DyCalculationInputs is now a pure value object (zero callables) (Plan 069)

  • Aave CLI boundary decoupling β€” domain types (ScaledTokenEvent, Operation, TransactionOperations, TransactionValidationError) moved from cli/aave_transaction_operations.py to aave/operations.py; TokenType moved to aave/types.py; decode_address moved to contract/decoding.py; dead code deleted (AAVE_EVENT_TOPIC_TO_CATEGORY, filter_scaled_events/find_first_scaled_event); aave/ now has zero cli/ imports (Plan 075)

Migration GuideΒΆ

Converting from Direct I/O to FetchersΒΆ

Before (Direct I/O):

class PoolWithIo:
    def __init__(self, address):
        self.address = address
        self._provider = _get_provider_for_chain(self.chain_id)  # ❌ Direct I/O
    
    def _get_stored_rates(self, block_number) -> tuple[int, ...]:
        rates = []
        for i, token in enumerate(self.tokens):
            if token.is_lending:
                # ❌ Pool doing I/O directly
                rate = self._provider.call(token.address, "exchangeRateStored", block_number)
                rates.append(rate)
        return tuple(rates)

After (Fetcher Pattern):

class IoFreePool:
    def __init__(self, address, data_provider: CurveDataProvider | None = None):
        self.address = address
        self._data_provider = data_provider  # βœ… Injected seam
    
    def _get_stored_rates(self, block_number) -> tuple[int, ...]:
        if self._data_provider is None:
            raise MissingCurveData("No data_provider provided")
        # βœ… Pure delegation: pool doesn't know about providers
        rates = []
        for token in self.lending_tokens:
            rate = self._data_provider.lending_rate(block_number, token.address)
            rates.append(rate)
        return tuple(rates)

Builder-side (Bot delegates to builders, not pools):

# Builders own the I/O choreography
# Bot.create_builder() injects connections and db into builders
# Builders call providers, construct pools with pure data

# V2 construction: builder fetches from DB/RPC, pool receives pure values
class V2PoolBuilder:
    def build(self, pool_address, *, chain_id, ...):
        provider = self._connections.get_provider(chain_id)  # Builder handles I/O
        ...
        pool = UniswapV2Pool(
            address=pool_address,
            token0=token0, token1=token1,
            reserves_token0=reserves0, reserves_token1=reserves1,
            # No provider reference passed to pool
        )

# Curve construction: builder creates CurveDataProviderImpl, injects into pool
class CurvePoolBuilder:
    def build(self, address, *, chain_id, ...):
        data_provider = CurveDataProviderImpl(
            provider=ProviderAdapter(...),
            pool_address=address,
            ...,
        )
        pool = CurveStableswapPool(
            ...,
            data_provider=data_provider,  # Single parameter replaces 13 fetcher callbacks
        )

When to UseΒΆ

Use I/O-Free Architecture When:ΒΆ

  1. Testing matters: You need deterministic unit tests without network

  2. Multiple I/O sites: Pool needs rates, virtual prices, timestamps, balances, etc. (Curve uses a single CurveDataProvider with 13 methods instead of 13 separate callbacks)

  3. Cross-cutting concerns: A single pool type needs different fetch strategies (e.g., cached vs real-time)

  4. Separation of concerns: Core logic should be pure, testable

  5. Async complexity: Pool doesn’t care if fetcher is sync or async (caller manages)

Keep Direct I/O When:ΒΆ

  1. Single I/O call: Constructor only needs one thing

  2. Simple fetch: No complex error handling or caching needed

  3. Performance: Avoids function call overhead (marginal in practice)

Testing with Fake Data ProvidersΒΆ

def test_pool_calculation():
    # Pure test: no mocking, no providers
    from degenbot.curve.types import CurveDataProvider

    class FakeCurveDataProvider:
        """Fixed-return test double for CurveDataProvider."""

        def D(self, block_number):
            return 3 * 10**18

        def gamma(self, block_number):
            return 10**18

        def virtual_price(self, block_number):
            return 10**18 + 10**16

        def base_virtual_price(self, block_number):
            return 10**18

        def price_scale(self, block_number):
            return (10**18,)

        def admin_balances(self, block_number):
            return (0, 0, 0)

        def lending_rate(self, block_number, token_address):
            return 10**18

        def redemption_price(self, block_number):
            return 10**18

        def block_timestamp(self, block_number):
            return 1234567890

        def block_number(self):
            return 100

        def token_balance(self, block_number, token_address):
            return 10**18

        def token_total_supply(self, block_number, token_address):
            return 10**18

        def is_crypto(self):
            return False

    pool = CurveStableswapPool(
        address="0x1234...",
        data_provider=FakeCurveDataProvider(),
        A=1000,
        tokens=[FAKE_DAI, FAKE_USDC],
    )

    # Test calculation logic only
    result = pool.calculate_swap(amount_in=1000_000, token_in=0, token_out=1, block=100)
    assert result == expected_amount

Error HandlingΒΆ

Fetchers (accessed via CurveDataProvider methods) should handle I/O errors and return appropriate values or raise domain exceptions:

def rate_fetcher(block_number: int) -> tuple[int, ...]:
    try:
        return provider.call(...)
    except ContractLogicError as e:
        # Convert to domain exception
        raise MissingCurveData(f"Rate fetch failed at block {block_number}") from e

The pool catches MissingCurveData (not ContractLogicError or ConnectionError), keeping pool logic provider-agnostic.

Curve-Specific ImplementationΒΆ

Curve pools use a CurveDataProvider seam β€” a single @runtime_checkable protocol with 13 methods that replaces the former 13 individual fetcher callback parameters. The pool calls self._data_provider.xxx() on-demand; the builder creates a CurveDataProviderImpl that wraps a ProviderAdapter.

Calculators receive a DyCalculationInputs frozen dataclass instead of the pool object. The pool’s get_dy() performs all I/O (rate resolution, cache lookups, block data) before constructing a DyCalculationInputs with pre-resolved values (including d_variant/y_variant/yd_variant/a_precision for variant-aware invariant solving) and passing it to the calculator. Calculators call pure stableswap_get_y() / stableswap_newton_y() directly β€” no closures, no pool references. This eliminates all private member access from calculators (Plan 045, Plan 069).

On-chain data caches are owned by a PerBlockCache object (self._cache) on CurveStableswapPool, with get_cached_* accessor methods implementing the try-cache→call-provider→store→return pattern. Formerly CurveOnChainCache (Plan 054), absorbed into the pool as private fields (Plan 068), then extracted into PerBlockCache with mirror-free design (Plan 077). get_cached_virtual_price() resolves its own dependencies inline by calling get_cached_base_cache_updated() and get_cached_base_virtual_price(), eliminating the former side-effect mirrors (_base_cache_updated_value, _base_virtual_price_value).

Each strategy enum (SwapStyle, MetapoolRateStyle, MetapoolUnderlyingStyle) has a make_calculator() factory method that returns the matching DyCalculator instance. PoolStrategies auto-constructs calculators from enum values in __post_init__ via these factory methods; explicitly-passed calculator arguments are preserved (Plan 056).

Method

Purpose

When Needed

D()

On-chain invariant D value

Crypto pools

gamma()

Gamma parameter

Crypto pools

virtual_price()

Base pool virtual price

Metapools

base_virtual_price()

Base pool virtual price (alternate)

Metapools

price_scale()

On-chain price_scale values

Crypto pools

admin_balances()

Admin fee balances

Pools tracking admin fees

lending_rate()

Per-token lending rates

Lending pools

redemption_price()

LSD redemption price

Pools wrapping stETH, frxETH

block_timestamp()

Block timestamps for A ramping

Pools with ramping A

block_number()

Current block number

All pools

token_balance()

Token balance at block

Metapools

token_total_supply()

Token total supply

Metapools

is_crypto()

Whether pool uses CryptoSwap

All pools (flag)

See src/degenbot/curve/types.py for protocol definition and src/degenbot/curve/data_provider_impl.py for CurveDataProviderImpl.

ReferencesΒΆ

  • src/degenbot/curve/types.py β€” CurveDataProvider protocol, DyCalculationInputs dataclass, DyCalculator protocol definitions

  • src/degenbot/types/pool_protocols.py β€” Pool simulation and cacheable protocols

  • the completed 0-series implementation plans (017 v2/v3 io-free migration, 019 CacheablePool protocol, 045 DyCalculationInputs explicit data; the plans/ tree was removed in 6f509d44f)

  • docs/adr/ADR-001-io-free-pools.md β€” ADR-001 (I/O-free pools)


Last updated: 2026-05-21