auradefi 0.1.1
PyPI GitHub

How do I get DeFi positions, an LP, a loan, and not lie about them?

Run it

pip install auradefi
python examples/07_read_defi_positions.py

A wallet balance is one number. A DeFi position is a claim on other assets, sometimes negative, and the way most tools get it wrong is to flatten it too early. Here the shape is:

adapter.discover() -> which contracts matter on this chain
adapter.resolve()  -> RAW positions: quantities, no prices at all
drill(positions, prices) -> value them, group them, net them
project_to_synthetic_holdings(drilled) -> the flat Plaid-shaped view

Two properties this file proves rather than asserts in prose:

Chain reads go through ONE seam: call(address, fn, args). This file binds a dict of recorded answers, which is also how the shipped adapters are tested. No concrete on-chain reader ships in the package: there is no eth_call transport and no multicall batcher, so running these adapters against mainnet means writing that call yourself (README, What is not there).

What it prints

resolved 2 positions in group grp_0f89caffe413b09f:
  deposit  10 ETH (supplied)
  loan     5000 USDC (borrowed)
  health factor 5.812500000000000000, ltv 0.8000
  4 balance probes -> 2 positions (zero balances dropped)

gross 35841.70000000000000000000 USD
debt  4999.195000000000 USD
net   30842.50500000000000000000 USD

flat holdings a Plaid-only client sees:
  10.000000000000000000 @   3584.17 USD = 35841.70000000000000000000 USD
  -5000.000000 @  0.999839 USD = -4999.195000000000 USD
      sum                   30842.50500000000000000000 == net worth: the invariant, by exact Decimal equality

ETH re-priced at 3600 -> net 31000.805000000000000000 USD, 0 extra chain reads

resolve_all over 2 adapters: 2 positions, failures=[('some-protocol', "RuntimeError('upstream ABI changed')")]

OK: raw positions, one grouping, and a flat view that still adds up.

The whole file

07_read_defi_positions.py on GitHub: self-contained, offline, asserts its own output.

"""How do I get DeFi positions, an LP, a loan, and not lie about them?

    pip install auradefi
    python examples/07_read_defi_positions.py

A wallet balance is one number. A DeFi position is a claim on other assets,
sometimes negative, and the way most tools get it wrong is to flatten it too
early. Here the shape is:

    adapter.discover() -> which contracts matter on this chain
    adapter.resolve()  -> RAW positions: quantities, no prices at all
    drill(positions, prices) -> value them, group them, net them
    project_to_synthetic_holdings(drilled) -> the flat Plaid-shaped view

Two properties this file proves rather than asserts in prose:

* **the projection invariant.** A client that knows nothing about DeFi, sums
  `institution_value` over the flat holdings and trusts the number, gets
  EXACTLY the net worth, because debt projects as a negative *quantity* at
  a positive price, never a negative price;
* **re-pricing costs zero chain reads.** Positions carry raw quantities, so
  a new price is a pure recomputation. Nothing to invalidate, nothing stale.

Chain reads go through ONE seam: `call(address, fn, args)`. This file binds
a dict of recorded answers, which is also how the shipped adapters are
tested. **No concrete on-chain reader ships in the package**: there is no
`eth_call` transport and no multicall batcher, so running these adapters
against mainnet means writing that `call` yourself (README, *What is not
there*).
"""

from __future__ import annotations

from decimal import Decimal

from auradefi.money.fiat import Money
from auradefi.positions.adapters.lending.aave import AaveV3Adapter, Market
from auradefi.positions.drill import drill, project_to_synthetic_holdings
from auradefi.positions.models import MetaType, PositionKind, PositionType
from auradefi.positions.protocol import (
    ContractSet,
    DiscoveryContext,
    PositionAdapter,
    ResolveContext,
)
from auradefi.positions.registry import AdapterRegistry
from auradefi.positions.resolve import resolve_all

CHAIN = "eip155:1"
BLOCK = 20_450_000
WALLET = "0x00000000000000000000000000000000000a11ce"

POOL = "0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2"
AWETH, DEBT_WETH = "0x4d5f47fa6a74757f35c14fd3a6ef8e3c9bc514e8", "0xea51d7853eefb32b6ee06b1c12e6dcca88be0ffe"
AUSDC, DEBT_USDC = "0x98c23e9d8f34fefb1b7bd6a91b7ff122f4e16f5c", "0x72e95b8931767c79ba4eee721354d6e99a61d004"
ETH = "eip155:1/slip44:60"
USDC = "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"


class RecordedReader:
    """The entire chain-read seam: `call(address, fn, args) -> value`.

    Put your `eth_call` + multicall behind this one method and every shipped
    adapter works. It is the only thing standing between these adapters and
    a live chain.
    """

    def __init__(self, answers: dict) -> None:
        self._answers = dict(answers)
        self.calls: list[tuple] = []

    def call(self, address: str, fn: str, args: tuple = ()) -> object:
        self.calls.append((address.lower(), fn, args))
        return self._answers[(address.lower(), fn, args)]


class MainnetAaveV3(AaveV3Adapter):
    """The shipped adapter, told which markets to look at."""

    markets = (Market(AWETH, DEBT_WETH, ETH, 18),
               Market(AUSDC, DEBT_USDC, USDC, 6))


# SPEC §6.3's worked example: supply 10 ETH, borrow 5,000 USDC. ONE risk unit.
reader = RecordedReader({
    (AWETH, "balanceOf", (WALLET,)): 10 * 10**18,       # 10 ETH supplied
    (DEBT_WETH, "balanceOf", (WALLET,)): 0,
    (AUSDC, "balanceOf", (WALLET,)): 0,
    (DEBT_USDC, "balanceOf", (WALLET,)): 5_000 * 10**6,  # 5,000 USDC borrowed
    (POOL, "getUserAccountData", (WALLET,)): (
        3_584_250_000_000, 500_000_000_000, 2_367_400_000_000,
        8250, 8000, 5_812_500_000_000_000_000,          # …ltv, health factor
    ),
})

adapter = MainnetAaveV3()
assert isinstance(adapter, PositionAdapter)   # a Protocol: no base class needed

# ------------------------------------------------------- 1. discover + resolve
contracts = adapter.discover(DiscoveryContext(chain_id=CHAIN, reader=reader))
supply, borrow = adapter.resolve(
    ResolveContext(chain_id=CHAIN, address=WALLET, reader=reader, block_number=BLOCK),
    contracts,
)

# Raw quantities only. No price reached this layer, so nothing here can go
# stale, and the sign of a debt lives in `meta_type`, not in the number.
assert supply.position_type is PositionType.DEPOSIT
assert borrow.position_type is PositionType.LOAN
assert borrow.kind is PositionKind.CONTRACT_POSITION      # no 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
assert all(under.price is None for under in supply.underlyings + borrow.underlyings)

# The two are ONE risk unit and say so with a shared group id. You cannot
# show the collateral without the debt by accident.
assert supply.group_id == borrow.group_id
print(f"resolved 2 positions in group {supply.group_id}:")
print(f"  {supply.position_type.value:<8} {supply.underlyings[0].quantity} ETH "
      f"({supply.underlyings[0].meta_type.value})")
print(f"  {borrow.position_type.value:<8} {borrow.underlyings[0].quantity} USDC "
      f"({borrow.underlyings[0].meta_type.value})")
print(f"  health factor {supply.group_info.health_factor}, "
      f"ltv {supply.group_info.ltv}")

# Four markets were probed; the two zero balances produced no position at all.
probes = [call for call in reader.calls if call[1] == "balanceOf"]
assert len(probes) == 4
print(f"  {len(probes)} balance probes -> 2 positions (zero balances dropped)")

# ------------------------------------------------------------ 2. value it
prices = {ETH: Money(Decimal("3584.17"), "USD"),
          USDC: Money(Decimal("0.999839"), "USD")}
drilled = drill([supply, borrow], prices)

assert drilled.gross_assets.amount == Decimal("35841.70")
assert drilled.total_debt.amount == Decimal("4999.195")
assert drilled.net_worth.amount == Decimal("30842.505")
print(f"\ngross {drilled.gross_assets}")
print(f"debt  {drilled.total_debt}")
print(f"net   {drilled.net_worth}")

# --------------------------------------------------- 3. the flat projection
holdings = project_to_synthetic_holdings(drilled)
by_asset = {holding.asset_id: holding for holding in holdings}

assert by_asset[ETH].quantity == Decimal("10")
assert by_asset[USDC].quantity == Decimal("-5000")        # the QUANTITY is negative
assert by_asset[USDC].institution_price.amount > 0        # the PRICE never is
naive_total = sum((holding.institution_value.amount for holding in holdings), Decimal("0"))
assert naive_total == drilled.net_worth.amount

print("\nflat holdings a Plaid-only client sees:")
for holding in holdings:
    print(f"  {str(holding.quantity):>7} @ {str(holding.institution_price):>13}"
          f" = {holding.institution_value}")
print(f"  {'sum':>7}   {'':>13}   {naive_total} == net worth: "
      "the invariant, by exact Decimal equality")

# ------------------------------------------------- 4. re-price, zero reads
reads_before = len(reader.calls)
repriced = drill([supply, borrow], {**prices, ETH: Money(Decimal("3600"), "USD")})
rebuilt = sum((holding.institution_value.amount
               for holding in project_to_synthetic_holdings(repriced)), Decimal("0"))
assert repriced.net_worth.amount == Decimal("31000.805") == rebuilt
assert len(reader.calls) == reads_before
print(f"\nETH re-priced at 3600 -> net {repriced.net_worth}, "
      f"{len(reader.calls) - reads_before} extra chain reads")

# --------------------------------------------- 5. one broken adapter, contained
# Register many adapters and resolve them together. A protocol whose ABI
# changed under you fails on its own row: you get every other position plus
# a named failure, never a half-built portfolio presented as whole.
class BrokenAdapter:
    id = "some-protocol"
    chains = frozenset({CHAIN})

    def discover(self, ctx):
        return ContractSet.empty()

    def resolve(self, ctx, contracts):
        raise RuntimeError("upstream ABI changed")


registry = AdapterRegistry()
registry.register(adapter)
registry.register(BrokenAdapter())
outcome = resolve_all(
    registry.adapters(),
    ResolveContext(chain_id=CHAIN, address=WALLET, reader=reader, block_number=BLOCK),
    {"aave-v3": contracts, "some-protocol": ContractSet.empty()},
)
assert [position.id for position in outcome.positions] == [supply.id, borrow.id]
assert [failure.adapter_id for failure in outcome.failures] == ["some-protocol"]
print(f"\nresolve_all over {len(registry.adapters())} adapters: "
      f"{len(outcome.positions)} positions, failures="
      f"{[(f.adapter_id, f.error) for f in outcome.failures]}")

print("\nOK: raw positions, one grouping, and a flat view that still adds up.")