Skip to content

LiveProvider

LiveProvider reads pool state directly from a chain RPC and turns it into a PoolSnapshot. From there, StateTwinBuilder constructs a usable exchange object — and every primitive in DeFiPy works against it the same way it works against a MockProvider recipe.

v2.1 supports Uniswap V2 and V3. Balancer and Stableswap LiveProvider implementations are v2.2 work. For those protocols today, use MockProvider recipes.

LiveProvider lives in the core package, but the chain-reading machinery (web3, web3scout) is an optional install. Two install paths cover the common cases:

Terminal window
pip install 'defipy[chain]'

Adds web3.py and web3scout on top of the core install. Use this when you want LiveProvider for analytics or simulation, without the MCP server layer.

Without [chain] (or [agentic]), importing LiveProvider works but calling .snapshot() raises an import error pointing at the missing dependencies.

Pull V2 pool state, run a primitive against it, and reach the underlying web3.Web3 instance via get_w3() for any direct chain manipulation.

from defipy.twin import LiveProvider, StateTwinBuilder
provider = LiveProvider("https://eth-mainnet.g.alchemy.com/v2/<key>")
# WETH/USDC V2 mainnet pool
snap = provider.snapshot(
"uniswap_v2:0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"
)
lp = StateTwinBuilder().build(snap)
# Same shape as any other twin — every primitive works against it
from defipy import CheckPoolHealth
health = CheckPoolHealth().apply(lp)
print(f"TVL in WETH: {health.tvl_in_token0:.2f}")
print(f"Spot price: {health.spot_price:.4f}")
# Reach the underlying web3.Web3 directly when you need it — see
# "Signing transactions: bring your own" below for the read-only-by-design framing.
w3 = provider.get_w3()
print(f"Chain ID: {w3.eth.chain_id}")

Same pattern for V3 pools, with the V3-specific result fields (fee_pips, tick_current) populated. The get_w3() escape hatch behaves identically — one connection per provider, shared with the snapshot path.

from defipy.twin import LiveProvider, StateTwinBuilder
provider = LiveProvider("https://eth-mainnet.g.alchemy.com/v2/<key>")
# USDC/WETH V3 500bps mainnet pool
snap = provider.snapshot(
"uniswap_v3:0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640"
)
lp = StateTwinBuilder().build(snap)
# All V3 primitives work — pool health, position analysis, slippage, scenarios
from defipy import CheckPoolHealth
health = CheckPoolHealth().apply(lp)
print(f"V3 fee: {health.fee_pips} pips")
print(f"TVL ratio: {health.tvl_in_token0 / health.tvl_in_token1:.2e}")
# Same w3 escape hatch as V2 — see "Signing transactions: bring your own" below.
w3 = provider.get_w3()
print(f"Block: {w3.eth.block_number}")

The first argument to .snapshot() is a string of the form "<protocol>:<address>":

uniswap_v2:0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc
uniswap_v3:0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640

Address casing is normalized internally — lowercase, uppercase, or EIP-55 checksum-mixed all work. The protocol prefix selects the read path. Valid protocols:

PrefixStatus
uniswap_v2v2.1 (works)
uniswap_v3v2.1 (works)
balancerv2.2+ (raises NotImplementedError)
stableswapv2.2+ (raises NotImplementedError)

Malformed pool_id (missing colon, empty protocol, empty address) raises ValueError with a message naming the offending input and listing the valid protocols.

By default, LiveProvider resolves "latest" to a concrete block number once at the start of .snapshot(), then pins every subsequent read to that block. State drift across reads inside one snapshot is impossible — you get a coherent view of the pool at one specific block.

To read a historical block, pass block_number:

# Snapshot WETH/USDC V2 at block 19,500,000
snap = provider.snapshot(
"uniswap_v2:0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc",
block_number=19_500_000,
)

The resolved block_number is also written onto the snapshot itself (see Chain context fields below), so downstream consumers know exactly which block the data came from.

For V3 pools, the snapshot represents the pool’s active liquidity as if it were a single position spanning a tick range [lwr_tick, upr_tick]. By default the range is full-rangegetMinTick(tick_spacing) to getMaxTick(tick_spacing) — which gives MockProvider-parity twins and works cleanly with all V3 active-liquidity primitives.

To narrow the range — for example, to simulate a tighter concentrated position around the active tick — pass lwr_tick / upr_tick kwargs:

# Tight range around active tick — useful for IL-at-concentration scenarios
snap = provider.snapshot(
"uniswap_v3:0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640",
lwr_tick=-600,
upr_tick=600,
)

The amounts derived for the snapshot scale with the chosen range. Three regimes:

  • Active tick inside range — both reserve0 and reserve1 are nonzero
  • Active tick at or below lwr_tick — single-sided in token0; reserve1 == 0
  • Active tick at or above upr_tick — single-sided in token1; reserve0 == 0

The single-sided regimes are valid V3 semantics, not error states. They reflect the real shape of a position whose range has been crossed.

Both V2 and V3 snapshots have reserve0 and reserve1 as Python floats in whole-token units, not raw uint112 / uint128 wei values. LiveProvider divides by 10 ** decimals per token before constructing the snapshot.

This matches MockProvider’s contract — the same reserve0 = 1000.0 shape — and lets StateTwinBuilder and every primitive consume the snapshot uniformly regardless of source. If you need the raw values, query the chain directly with web3.py; LiveProvider’s job is to produce values that compose cleanly with the rest of DeFiPy.

V3 snapshots batch all pool reads (token0, token1, slot0, liquidity, fee, tickSpacing, plus getCurrentBlockTimestamp) into a single Multicall3 aggregate3 call. That’s one RPC round trip for the V3-specific reads, plus separate sequential reads for the two tokens’ symbol and decimals (which web3scout’s FetchToken doesn’t fold into multicall).

V2 snapshots use sequential reads — four eth_calls pinned to the resolved block, plus eth_getBlockByNumber for the timestamp. The multicall optimization buys less for V2’s smaller read set.

The Multicall3 contract address (0xcA11bde05977b3631167028862bE2a173976CA11) is hardcoded; this address is the same on every major EVM chain Multicall3 has been deployed to. Chains without Multicall3 will fail with a descriptive error — that’s a v2.2 problem.

Every snapshot from LiveProvider carries three fields populated from the chain:

FieldSource
block_numberThe resolved concrete block — the value of block_number kwarg if passed, otherwise the result of eth_blockNumber at the top of .snapshot()
timestampBlock header timestamp (V2) or Multicall3’s getCurrentBlockTimestamp() pinned to the resolved block (V3)
chain_ideth_chainId, cached on the underlying RpcClient after first read so repeat snapshots on the same provider don’t re-fetch

These fields are Optional[int] = None on the snapshot dataclasses. MockProvider leaves them None — synthetic snapshots have no chain context, and inventing fake values would be dishonest. Consumers that need chain context check for None and branch:

snap = provider.snapshot("uniswap_v2:0x...")
if snap.block_number is not None:
print(f"Live snapshot pinned at block {snap.block_number}")
print(f"Timestamp: {snap.timestamp}, Chain ID: {snap.chain_id}")

This is the substrate consumers like caching layers, reorg detectors, or multi-chain routers build on. LiveProvider provides the data; what the consumer does with it is their concern.

Snapshots are stateless across calls — no caching of pool state, block data, or snapshot results between .snapshot() invocations. Each call returns a fresh snapshot.

The underlying RPC connection, however, is cached on the LiveProvider instance for efficiency. The first call to .snapshot() or get_w3() (whichever comes first) constructs the RpcClient lazily; subsequent calls on the same provider reuse it. Both methods share one connection per LiveProvider instance for its lifetime.

For long-running processes that may see the connection go stale, construct a fresh LiveProvider periodically or build your own connection-management layer around get_w3().

LiveProvider exposes a small surface — a constructor, the snapshot method, and an escape hatch to the underlying web3 instance.

MethodReturnsPurpose
LiveProvider(rpc_url: str)LiveProviderConstruct a provider against the given RPC endpoint. No chain reads happen until .snapshot() or .get_w3() is called.
.snapshot(pool_id, *, block_number=None, lwr_tick=None, upr_tick=None)PoolSnapshotRead pool state and return a typed snapshot. pool_id is "<protocol>:<address>". block_number defaults to "latest" (resolved once at the top of the call); pass an int for historical reads. lwr_tick / upr_tick apply to V3 pools only — they default to full-range.
.get_w3()web3.Web3Return the underlying web3.Web3 instance. See Signing transactions: bring your own below.

All three respect the lazy-import contract: importing LiveProvider does not require [chain] to be installed; calling either of the two methods does. Without the install, the call surfaces a clear ImportError pointing at the missing extras.

LiveProvider is read-only by design. The substrate exposes pool state via typed snapshots; it does not sign or send transactions. Signing infrastructure varies enormously across users — local key, hardware wallet, MPC vault, signing service, hosted custodial flow — and embedding any opinion would be wrong for most. DeFiPy stays out of the keys-and-execution layer because that’s where security and policy opinions diverge most.

For consumers who need to act on-chain after analysis, the underlying web3.Web3 instance is available via provider.get_w3():

from defipy.twin import LiveProvider
provider = LiveProvider("https://eth-mainnet.g.alchemy.com/v2/<key>")
w3 = provider.get_w3()
# From here, your signing infrastructure takes over.
# DeFiPy doesn't ship a signing path; you bring your own.
latest_block = w3.eth.block_number
chain_id = w3.eth.chain_id

The web3.Web3 instance is shared with the snapshot path — both provider.get_w3() and provider.snapshot(...) use the same connection, lazily constructed on first use and reused for the life of the LiveProvider instance.

What’s not here, by design: no provider.sign(), no provider.send_transaction(), no transaction-builder pattern, no key management, no gas estimation helpers. The substrate exposes the underlying web3 instance and stops. Transaction tooling beyond get_w3() is the consumer’s domain or sibling-library territory, not DeFiPy’s.

ConditionOutcome
Malformed pool_id (no colon, empty parts)ValueError with message and valid-protocols list
Unknown protocol prefixValueError
Known-but-unimplemented protocol (balancer, stableswap)NotImplementedError pointing at v2.2
lwr_tick >= upr_tick for V3ValueError from LiveProvider (caught before chain reads)
RPC unreachable / pool address has no contract / multicall revertsUnderlying web3.py exception propagates

For V3, if any single call inside the multicall reverts, the whole snapshot fails — allowFailure=False is set on every call. The intent is loud failure; a half-populated snapshot is worse than no snapshot.

  • Balancer LiveProvider — V3 of the same pattern for Balancer 2-asset weighted pools
  • Stableswap LiveProvider — same for Curve-style stableswap pools
  • V3 tick bitmap walking — pairs with AssessLiquidityDepth, enables non-active-tick analyses
  • Anvil fork support — optional CI lane against a forked node for higher-confidence integration tests

See the Roadmap for the full v2.2+ timeline.