PyBook 08: Positions
What this notebook proves: "the adapter protocol + a Uniswap-v2, an
Aave, and a liquid-staking adapter; drill-down to underlying assets;
synthetic-Holdings projection. Done when: the projection invariant
holds."
A balance says you hold 850 LP tokens. A position says what those LP
tokens are: a share of a USDC/WETH pool, worth its pro-rata slice of the
reserves. The job splits in two, and the split is the whole design:
- discover, which contracts is this address involved with?
- resolve, given those contracts, what does it hold right now?
Adapters return raw integers only, no prices, no USD.
Pricing is a separate pass, so a portfolio re-prices without a single chain
read. Nothing in this book touches the network: the chain is a
dict-backed ContractReader, frozen at Ethereum block 20,450,000.
from auradefi.positions.protocol import (
ContractDescriptor,
ContractSet,
DiscoveryContext,
PositionAdapter,
ResolveContext,
)
BLOCK = 20_450_000
CHAIN = "eip155:1"
ADDRESS = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"
class DictReader:
"The only chain-read seam: (address, fn, args) -> value. No I/O."
def __init__(self, responses):
self._responses = dict(responses)
self.calls = []
def call(self, address: str, fn: str, args: tuple = ()) -> object:
self.calls.append((address.lower(), fn, args))
return self._responses[(address.lower(), fn, args)]
# `PositionAdapter` is a runtime-checkable Protocol: an adapter is anything
# with an id, a chain set, discover() and resolve().
from auradefi.positions.adapters.amm.uniswap_v2 import UniswapV2Adapter
assert isinstance(UniswapV2Adapter(), PositionAdapter)
assert isinstance(DictReader({}), type(DictReader({})))
print("adapter protocol:", sorted(m for m in dir(PositionAdapter) if not m.startswith("_")))
adapter protocol: ['discover', 'resolve']
A Uniswap v2 LP position: pro rata, in integers
The maths is one line and it must be exact:
your share of reserve_i = balanceOf(you) * reserve_i // totalSupply
Integer floor division, never a float. The fixture is the USDC/WETH pair at
block 20,450,000: you hold 850,000,000,000,000 of 850,000,000,000,000,000
LP units: 0.1% of the pool.
Position and group ids are sha256 over pinned preimages
(docs/internal/DECISIONS.md), so they are stable across processes, machines and
releases: pos_e463a531f5d6a400 is that pair position forever.
from auradefi.money.quantity import Quantity
from auradefi.positions.models import MetaType, PositionKind, PositionType, ProtocolModule
USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
WETH = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
V2_PAIR = "0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc"
reader = DictReader({
(V2_PAIR, "balanceOf", (ADDRESS,)): 850_000_000_000_000,
(V2_PAIR, "totalSupply", ()): 850_000_000_000_000_000,
(V2_PAIR, "getReserves", ()): (52_000_000_000_000, 14_500_000_000_000_000_000_000, 1_722_470_000),
(V2_PAIR, "token0", ()): USDC,
(V2_PAIR, "token1", ()): WETH,
(USDC, "decimals", ()): 6,
(WETH, "decimals", ()): 18,
})
descriptor = ContractDescriptor(
adapter_id="uniswap-v2", chain_id=CHAIN, address=V2_PAIR, category="amm-pair",
underlyings=(f"{CHAIN}/erc20:{USDC}", f"{CHAIN}/erc20:{WETH}"),
)
ctx = ResolveContext(chain_id=CHAIN, address=ADDRESS, reader=reader, block_number=BLOCK)
(lp,) = UniswapV2Adapter().resolve(ctx, ContractSet.of(descriptor))
assert lp.id == "pos_e463a531f5d6a400"
assert lp.group_id == "grp_b351d79d77bc24eb"
assert lp.kind is PositionKind.APP_TOKEN # the LP token IS the position
assert lp.position_type is PositionType.DEPOSIT
assert lp.protocol_module is ProtocolModule.LIQUIDITY_POOL
assert [(u.meta_type, u.quantity) for u in lp.underlyings] == [
(MetaType.SUPPLIED, Quantity(52_000_000_000, 6)), # 52,000 USDC
(MetaType.SUPPLIED, Quantity(14_500_000_000_000_000_000, 18)), # 14.5 WETH
]
# RAW only: no price, no value, nothing to go stale.
assert all(u.price is None and u.value is None for u in lp.underlyings)
assert lp.value is None
for underlying in lp.underlyings:
print(f"{underlying.meta_type.value:>8} {str(underlying.quantity):>10} {underlying.asset_id}")
supplied 52000 eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
supplied 14.5 eip155:1/erc20:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2
An Aave position: two rows, one risk unit
The worked example: supply 10 ETH, borrow 5,000 USDC. That is
two positions sharing one group_id, a supply APP_TOKEN and a
borrow CONTRACT_POSITION, because liquidation applies to the pair, not
to either leg. group_info (health factor, LTV) is attached once.
Note what the borrow does not do: the raw quantity stays positive.
The sign lives in meta_type=BORROWED. The negative number appears exactly
once, at the projection boundary, which is the next section.
from decimal import Decimal
from auradefi.positions.adapters.lending.aave import AaveV3Adapter, Market
POOL = "0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2"
AWETH, DEBT_WETH = "0x4d5f47fa6a74757f35c14fd3a6ef8e3c9bc514e8", "0xea51d7853eefb32b6ee06b1c12e6dcca88be0ffe"
AUSDC, DEBT_USDC = "0x98c23e9d8f34fefb1b7bd6a91b7ff122f4e16f5c", "0x72e95b8931767c79ba4eee721354d6e99a61d004"
ETH_ID, USDC_ID = "eip155:1/slip44:60", f"{CHAIN}/erc20:{USDC}"
USER = "0x00000000000000000000000000000000000a11ce"
class MainnetAaveV3(AaveV3Adapter):
markets = (Market(AWETH, DEBT_WETH, ETH_ID, 18), Market(AUSDC, DEBT_USDC, USDC_ID, 6))
aave_reader = DictReader({
(AWETH, "balanceOf", (USER,)): 10_000_000_000_000_000_000, # 10 ETH supplied
(DEBT_WETH, "balanceOf", (USER,)): 0,
(AUSDC, "balanceOf", (USER,)): 0,
(DEBT_USDC, "balanceOf", (USER,)): 5_000_000_000, # 5,000 USDC borrowed
(POOL, "getUserAccountData", (USER,)): (
3_584_250_000_000, 500_000_000_000, 2_367_400_000_000, 8250, 8000, 5_812_500_000_000_000_000,
),
})
adapter = MainnetAaveV3()
contracts = adapter.discover(DiscoveryContext(chain_id=CHAIN, reader=aave_reader))
supply, borrow = adapter.resolve(
ResolveContext(chain_id=CHAIN, address=USER, reader=aave_reader, block_number=BLOCK), contracts
)
assert (supply.id, borrow.id) == ("pos_baff12a5eafb77f6", "pos_1bbfb302ddabf62b")
assert supply.group_id == borrow.group_id == "grp_0f89caffe413b09f" # ONE risk unit
assert supply.position_type is PositionType.DEPOSIT
assert borrow.position_type is PositionType.LOAN
assert borrow.kind is PositionKind.CONTRACT_POSITION # no receipt token to hold
assert supply.underlyings[0].meta_type is MetaType.SUPPLIED
assert borrow.underlyings[0].meta_type is MetaType.BORROWED
assert borrow.underlyings[0].quantity.raw > 0 # sign lives in meta_type
assert supply.group_info.health_factor == Decimal("5.8125")
assert supply.group_info.ltv == Decimal("0.8")
# Zero balances are dropped: 4 markets probed, 2 positions emitted.
assert len([c for c in aave_reader.calls if c[1] == "balanceOf"]) == 4
print(f"supply {supply.underlyings[0].quantity} ETH ({supply.position_type.value})")
print(f"borrow {borrow.underlyings[0].quantity} USDC ({borrow.position_type.value}, meta={borrow.underlyings[0].meta_type.value})")
print(f"group {supply.group_id} health_factor={supply.group_info.health_factor} ltv={supply.group_info.ltv}")
supply 10 ETH (deposit)
borrow 5000 USDC (loan, meta=borrowed)
group grp_0f89caffe413b09f health_factor=5.812500000000000000 ltv=0.8000
Drill: price the raw, once
drill(positions, prices) walks every underlying, applies the price map,
and returns the signed triple:
gross_assets, everything with a non-borrowed meta type;
total_debt, everything BORROWED, as a positive magnitude;
net_worth = gross_assets − total_debt.
Multiplication is context-free exact Decimal (rounding: none:
docs/internal/DECISIONS.md), so drilling is deterministic at any magnitude.
from auradefi.money.fiat import Money
from auradefi.positions.drill import drill, project_to_synthetic_holdings
prices = {
ETH_ID: Money(Decimal("3584.17"), "USD"),
USDC_ID: Money(Decimal("0.999839"), "USD"),
}
drilled = drill([supply, borrow], prices)
assert drilled.gross_assets.amount == Decimal("35841.70") # 10 x 3584.17
assert drilled.total_debt.amount == Decimal("4999.195") # 5000 x 0.999839
assert drilled.net_worth.amount == Decimal("30842.505")
assert len(drilled.groups) == 1
assert drilled.groups[0].total_value.amount == drilled.net_worth.amount
print(f"gross {drilled.gross_assets}\ndebt {drilled.total_debt}\nnet {drilled.net_worth}")
gross 35841.70000000000000000000 USD
debt 4999.195000000000 USD
net 30842.50500000000000000000 USD
The projection invariant
The rule, verbatim:
"an Aave position supplying 10 ETH and borrowing 5,000 USDC emits two
synthetic Holdings. +10 ETH and a negative-quantity USDC Holding. A
Plaid-only client sums institution_value and gets the right net
worth."
That negative quantity is the Plaid extension that makes the naive sum
correct. Get the sign convention wrong and nothing errors. The net
worth is just silently wrong, which is the named casualty. So the
invariant is asserted by exact Decimal equality, and asserted again after
repricing to prove it is structural rather than a lucky pair of numbers.
holdings = project_to_synthetic_holdings(drilled)
by_asset = {holding.asset_id: holding for holding in holdings}
assert len(holdings) == 2
assert by_asset[ETH_ID].quantity == Decimal("10")
assert by_asset[USDC_ID].quantity == Decimal("-5000") # strictly negative
assert by_asset[USDC_ID].institution_price.amount > 0 # the PRICE never goes negative
assert by_asset[USDC_ID].institution_value.amount == Decimal("-4999.195000")
# What a Plaid-only client does: sum institution_value and trust it.
naive_total = sum((h.institution_value.amount for h in holdings), Decimal("0"))
assert naive_total == drilled.net_worth.amount == Decimal("30842.505")
for holding in holdings:
print(f"{str(holding.quantity):>7} @ {str(holding.institution_price):>13} = {holding.institution_value}")
print(f"{'sum':>7} {'':>13} {naive_total} USD == net worth {drilled.net_worth.amount}")
10.000000000000000000 @ 3584.17 USD = 35841.70000000000000000000 USD
-5000.000000 @ 0.999839 USD = -4999.195000000000 USD
sum 30842.50500000000000000000 USD == net worth 30842.50500000000000000000
# Same RAW positions, fresh ETH price, ZERO chain reads.
reads_before = len(aave_reader.calls)
repriced = drill([supply, borrow], {ETH_ID: Money(Decimal("3600"), "USD"), USDC_ID: prices[USDC_ID]})
rebuilt = sum((h.institution_value.amount for h in project_to_synthetic_holdings(repriced)), Decimal("0"))
assert repriced.net_worth.amount == Decimal("31000.805")
assert rebuilt == repriced.net_worth.amount
assert len(aave_reader.calls) == reads_before # not one extra call
print(f"re-priced at 3600: net worth {repriced.net_worth}: {len(aave_reader.calls) - reads_before} chain reads")
re-priced at 3600: net worth 31000.805000000000000000 USD: 0 chain reads
Failure is per-adapter, never per-portfolio
run_discovery / resolve_all isolate each adapter: one adapter throwing
does not blank the portfolio, it produces a named failure alongside the
positions that did resolve. A DeFi aggregator that returns nothing because
one protocol changed an ABI is worse than useless.
from auradefi.positions.registry import AdapterRegistry
from auradefi.positions.resolve import resolve_all
class BrokenAdapter:
id = "broken"
chains = frozenset({CHAIN})
def discover(self, ctx):
return ContractSet.empty()
def resolve(self, ctx, contracts):
raise RuntimeError("upstream ABI changed")
registry = AdapterRegistry()
registry.register(MainnetAaveV3())
registry.register(BrokenAdapter())
assert [a.id for a in registry.for_chain(CHAIN)] == ["aave-v3", "broken"]
outcome = resolve_all(
registry.adapters(),
ResolveContext(chain_id=CHAIN, address=USER, reader=aave_reader, block_number=BLOCK),
{"aave-v3": contracts, "broken": ContractSet.empty()},
)
assert [p.id for p in outcome.positions] == [supply.id, borrow.id]
assert [(f.adapter_id, "ABI" in f.error) for f in outcome.failures] == [("broken", True)]
print("positions:", len(outcome.positions), "| failures:", outcome.failures)
positions: 2 | failures: (AdapterFailure(adapter_id='broken', error="RuntimeError('upstream ABI changed')"),)
Honest edges: read this before trusting a number
- Positions are fixture-driven today. The
ContractReader seam is
shipped and every adapter is pinned to block-20,450,000 golden vectors,
but no concrete on-chain reader ships: there is no eth_call
transport and no multicall batcher in the package. A host must supply its
own reader to run these adapters against a live chain.
- Four adapters exist, Uniswap v2, Uniswap v3 (canonical TickMath),
Aave v3, and receipt-token liquid staking (Lido / Rocket Pool), all
Ethereum mainnet only.
apy is None everywhere: no yield source is wired.
- Discovery is registry-driven, not on-chain log scanning.
Next: 09_embedding: a host wiring the whole thing
into its own process.