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:
Testing is hard: Requires mocking providers, connection managers
Async complexity: Pool must handle sync/async boundaries
Coupling: Pool knows about infrastructure details (providers, chains, connections)
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:
Testability: Pass
FakeCurveDataProviderwith fixed return values; no mocking neededClean separation: Pool logic is pure; I/O lives in data provider implementations
Flexibility: Data providers can be sync or async; caller decides
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
_CurveDataProviderImplwrapping existing fetcher closuresTests use
FakeCurveDataProviderwith 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
CurveDataProviderseam (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 constructionBuilder extraction β pool construction I/O has been extracted from
Botinto typed builder classes (V2PoolBuilder,V3PoolBuilder,V4PoolBuilder,CurvePoolBuilder,Erc20Builder); V2 variant builders extracted intoV2BuilderBase+AerodromeV2Builder(Plan 043); ADR-005 slice 7 step 4b folded the deletedCamelotBuilderβs on-chain fetches intoV2PoolBuilder.build(Camelot branch keyed ondex.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/ifdispatch branches inget_dy()replaced by injectable calculator objects; pure math functions incalculations/stableswap.py(Plan 039)DyCalculationInputs β
pool: CurveStableswapPoolparameter inDyCalculator.calculate()replaced withinputs: DyCalculationInputsfrozen dataclass carrying pre-resolved data; 77 SLF001 errors β 0; calculators are pure consumers of pre-resolved data with no private member access (Plan 045).DyCalculationInputsis now a pure value object β all fields are ints, tuples, enums, or None (zero callables); calculators callstableswap_get_y()/stableswap_newton_y()directly withEVMRevertErrorwrapping (Plan 069).Curve state mixin β 25 attributes + 22 properties with
_xxxprivate pattern;StableswapPoolState(Plan 041)ProviderBackend β merged
EthereumProvider+_SyncProviderBackendβProviderBackendprotocol;__getattr__dispatch replaces 15Γ delegation methods (Plan 042);EthereumProviderbackward-compatibility alias removed (Plan 061); subscription stubs consolidated intoSyncSubscriptionSupport/AsyncSubscriptionSupportmixins (Plan 058)Builder Protocol β
PoolBuilderprotocol replaces the 4-way union type annotation;_dispatch_build()isinstance chain eliminated via**kwargsforwarding (Plan 035)Pool β Hop conversion β each poolβs
to_hop_state()is the single source of truth;solver_hop_builders.pydeleted (Plan 033)SwapAmounts consolidation β
input_amount()/output_amount()onAbstractSwapAmounts;build_swap_amount()on pool classes viaArbitragePathPoolprotocol;_extract_amount_in/outdeleted (Plan 036)Legacy arbitrage cycles β deleted;
AbstractArbitrageandget_arbitrage_helpers()deleted (Plan 038). Full removal of the_legacy/sub-package andUniswapLpCycle/UniswapCurveCycleredirects completes the migration toArbitragePath+ArbSolver.Bot typed builders deprecated β
build_v2_pool,build_v3_pool,build_v4_pool,build_curve_poolemitDeprecationWarning; usebuild_pool()(Plan 044). Removed by Plan 059.Functions module β
functions.pysplit into domain-aligned modules:provider/call_helpers.py,provider/log_fetching.py,contract/addresses.py,contract/decoding.py(decode_address),calculations/(EIP-1559next_base_fee, since moved to the Rust core),provider/block_helpers.py;eip_191_hashdeleted as dead code (Plan 037);decode_addressmoved fromcli/aave_utils.pytocontract/decoding.py(Plan 075)CurveDataProviderImpl β 850-line closure bag (
CurveFetcherFactory) replaced by structuredCurveDataProviderImpl(~350 lines) with real methods and shared I/O helpers (Plan 049)Curve per-block caches β 10 individual
BoundedCachefields consolidated intoCurveOnChainCache(Plan 054), absorbed intoCurveStableswapPoolas_cache_*fields with_get_cached_*accessors (Plan 068), then extracted intoPerBlockCacheclass 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
*Fetcherprotocol classes deleted fromcurve/types.py; superseded byCurveDataProvider(Plan 055)Strategy enum factory methods β
make_calculator()onSwapStyle,MetapoolRateStyle,MetapoolUnderlyingStyle;PoolStrategiesauto-constructs calculators from enum values (Plan 056)Calculation-time I/O β
_build_calculation_inputsβ_resolve_calculation_inputs_via_io,requires_io_at_calculation_timeproperty, ADR-001 amended with construction-time vs calculation-time I/O table (Plan 057)Old optimizer hierarchy β
ArbitrageOptimizerABC,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-levelCachedAbiTypesintern (stringArc<str>interner +Arc<CachedAbiTypes>value return +Arc<[Arc<str>]>key);Arc-sharedLogFilterfields;IntHopStatepre-converted U512 fields;PyPoolCachewithparking_lot::Mutex<LruCache>(10K cap) replacing unboundedHashMap; subscriptiondrain_raw()for pure-Rust buffer mechanics; concurrency stress tests;f64_to_u2564-limb decomposition fix; criterion benchmarks for ABI decode/encode and MΓΆbius solver (Plan 063)DyCalculationInputs pure value object β
get_y/newton_yclosure fields removed; replaced withd_variant/y_variant/yd_variant/a_precisionfields; calculators callstableswap_get_y()/stableswap_newton_y()directly withEVMRevertErrorwrapping;DyCalculationInputsis now a pure value object (zero callables) (Plan 069)Aave CLI boundary decoupling β domain types (
ScaledTokenEvent,Operation,TransactionOperations,TransactionValidationError) moved fromcli/aave_transaction_operations.pytoaave/operations.py;TokenTypemoved toaave/types.py;decode_addressmoved tocontract/decoding.py; dead code deleted (AAVE_EVENT_TOPIC_TO_CATEGORY,filter_scaled_events/find_first_scaled_event);aave/now has zerocli/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:ΒΆ
Testing matters: You need deterministic unit tests without network
Multiple I/O sites: Pool needs rates, virtual prices, timestamps, balances, etc. (Curve uses a single
CurveDataProviderwith 13 methods instead of 13 separate callbacks)Cross-cutting concerns: A single pool type needs different fetch strategies (e.g., cached vs real-time)
Separation of concerns: Core logic should be pure, testable
Async complexity: Pool doesnβt care if fetcher is sync or async (caller manages)
Keep Direct I/O When:ΒΆ
Single I/O call: Constructor only needs one thing
Simple fetch: No complex error handling or caching needed
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 |
|---|---|---|
|
On-chain invariant D value |
Crypto pools |
|
Gamma parameter |
Crypto pools |
|
Base pool virtual price |
Metapools |
|
Base pool virtual price (alternate) |
Metapools |
|
On-chain price_scale values |
Crypto pools |
|
Admin fee balances |
Pools tracking admin fees |
|
Per-token lending rates |
Lending pools |
|
LSD redemption price |
Pools wrapping stETH, frxETH |
|
Block timestamps for A ramping |
Pools with ramping A |
|
Current block number |
All pools |
|
Token balance at block |
Metapools |
|
Token total supply |
Metapools |
|
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 definitionssrc/degenbot/types/pool_protocols.pyβ Pool simulation and cacheable protocolsthe 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