PyBook 09: Embedding
What this notebook proves: "the embedding surface: from auradefi import Auradefi, the SQL ledger backend, the scalar projection. Done when: a
host can import, bind a session, and sync on its own tick."
This is the reason the project exists in this shape: "import, don't
call." A host with its own Python backend adopts auradefi as a library,
no HTTP hop, no serialisation, no second service to operate. Which means
the host owns everything a host owns:
| the host owns |
auradefi receives |
| the database engine, the DDL, the sessions |
a LedgerPort |
| the transport (keys, retries, rate limits) |
a source with two seams |
| prices |
a PriceOracle |
| time and the tick |
a Clock and a budget |
We never open a connection the host did not hand us. This book proves that
claim rather than asserting it, including by counting requests.
from pathlib import Path
def repo_root() -> Path:
"""The checkout this book lives in, wherever it is executed from."""
here = Path.cwd().resolve()
for candidate in (here, *here.parents):
if (candidate / "tests" / "cassettes").is_dir():
return candidate
raise RuntimeError("run this book from inside a checkout of auradefi")
CASSETTES = repo_root() / "tests" / "cassettes"
print("cassettes:", CASSETTES.relative_to(repo_root()))
cassettes: tests/cassettes
The host's database, built by the host
ledger/backends/models.py exposes SQLModel table definitions and their
metadata; SqlModelLedger takes a session factory. The library emits
no DDL and opens no connection. A fresh engine with no create_all has no
tables, and binding the facade against it changes nothing: the failure
only shows up when the host reads, which is exactly right.
from sqlalchemy import create_engine
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, select
from auradefi.ledger.backends.models import LedgerTransactionRow, metadata
from auradefi.ledger.backends.sqlmodel import SqlModelLedger
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
def session_factory() -> Session:
"The HOST's session factory: the library only ever calls this."
return Session(engine)
metadata.create_all(engine) # the host's DDL, on the host's schedule
ledger = SqlModelLedger(session_factory=session_factory)
assert LedgerTransactionRow.__tablename__ == "auradefi_ledger_transactions"
print("tables the host created:", sorted(metadata.tables))
tables the host created: ['auradefi_ledger_seqs', 'auradefi_ledger_transactions']
The host's transport: one adapter, two seams
source must structurally satisfy both BalanceSource (balances,
for holdings) and PageFetcher (fetch_txlist, for history). One object,
two methods, so a host writes one adapter. The check happens at bind
time: a source missing a seam raises ValidationError in the constructor,
not on a background tick at 3 a.m.
The CountingTransport below is the honest way to prove a no-op: count the
requests that leave the client. Timing proves nothing.
import httpx
from auradefi.chains.evm import chain_id_from_caip2
from auradefi.errors import SourceError, ValidationError
from auradefi.sources.evm.etherscan import EtherscanV2
from auradefi.testing.cassettes import load
BASE_URL = "https://api.etherscan.io/v2/api"
PAGE_SIZE = 2
class CountingTransport(httpx.BaseTransport):
"Counts every request that leaves the client, then replays it."
def __init__(self, inner):
self._inner, self.calls = inner, 0
def handle_request(self, request):
self.calls += 1
return self._inner.handle_request(request)
class HostSource:
"The host's adapter: balances via EtherscanV2, raw txlist pages by hand."
def __init__(self, client):
self._client = client
self._balances = EtherscanV2(client, api_key=None, page_size=PAGE_SIZE)
def balances(self, chain_id, address):
return self._balances.balances(chain_id, address)
def fetch_txlist(self, chain_id, address, *, start_block, end_block, page, offset, sort):
response = self._client.get(BASE_URL, params={
"chainid": str(chain_id_from_caip2(chain_id)), "module": "account",
"action": "txlist", "address": address, "startblock": str(start_block),
"endblock": str(end_block), "page": str(page), "offset": str(offset), "sort": sort,
})
envelope = response.json()
if envelope.get("status") == "0" and envelope.get("message") == "No transactions found":
return []
if envelope.get("status") != "1":
raise SourceError(f"etherscan txlist error: {envelope.get('message')!r}")
return list(envelope["result"])
transport = CountingTransport(load(CASSETTES / "embed_gate.json").transport())
client = httpx.Client(transport=transport)
source = HostSource(client)
print("seams:", [name for name in ("balances", "fetch_txlist") if hasattr(source, name)])
seams: ['balances', 'fetch_txlist']
from auradefi import Auradefi
from auradefi.clock import FrozenClock
from auradefi.embed.state import MemorySyncState
from auradefi.config import Settings
from auradefi.prices.inquirer import Inquirer
from auradefi.prices.oracles.defillama import DefiLlamaOracle
T0, INTERVAL_MS = 1_754_000_000_000, 60_000
clock = FrozenClock(T0)
state = MemorySyncState() # the HOST's cursor store; it outlives the facade
auradefi = Auradefi(
ledger,
source,
Inquirer([DefiLlamaOracle(client)]),
clock,
Settings(sync_min_interval_s=60),
sync_state=state,
sync_page_size=PAGE_SIZE,
)
assert transport.calls == 0, "binding is ZERO I/O"
class HalfASource:
def balances(self, chain_id, address):
return []
try:
Auradefi(ledger, HalfASource(), Inquirer([]), clock)
except ValidationError as exc:
print("refused at bind time:", exc)
print("bound with", transport.calls, "requests made")
refused at bind time: source must satisfy PageFetcher: no 'fetch_txlist'
bound with 0 requests made
Connect: validated now, not on a later tick
user(external_user_id) derives a tenant id from the host's opaque id under
settings.project_id (pure, no I/O, get-or-create for free): the same
derivation the HTTP API uses, so a host running both surfaces over one ledger
addresses one tenant. connect_address then:
- parses the CAIP-2 chain and checks
ChainRegistry membership: a
vendor name like "ethereum" and a well-formed chain the registry does
not hold are both refused before any HTTP, because the decoder needs
that entry and a stored connection nothing can decode fails on every
later tick;
- normalises the address and derives the chain-scoped connection id, so
a duplicate is a
ConflictError at zero requests while the same
address on another chain is a second, independent connection;
- issues exactly one cheap liveness probe.
A connector that accepts anything and fails silently hours later is the
worst failure mode for an embedding host, so the failure is moved to the
call the host's user is watching.
from auradefi.embed.models import derive_connection_id
from auradefi.errors import CaipParseError, ConflictError, UnknownChainError
CHAIN, ADDRESS = "eip155:1", "0x1111111111111111111111111111111111111111"
user = auradefi.user("host-user-1")
assert user.tenant_id == "usr_1e63721d071ea2d9" # sha256("embed|host-user-1")[:16]
before = transport.calls
connection = user.connect_address(CHAIN, ADDRESS)
assert transport.calls == before + 1 # exactly one probe
assert connection.id == "conn_d0327e21d9b0ea55"
assert connection.created_at_ms == T0
# The id is CHAIN-SCOPED: one address, two chains, two ids, so two
# independent sync cursors. Pure derivation, no I/O, nothing stored.
assert derive_connection_id(user.tenant_id, ADDRESS, "eip155:137") != connection.id
# A bad chain never reaches the network...
calls = transport.calls
try:
user.connect_address("ethereum", ADDRESS)
except CaipParseError as exc:
print("refused pre-flight:", exc)
# ...nor does a well-formed chain the registry does not hold: accepting it
# would store a connection every later sync() dies on.
try:
user.connect_address("eip155:42161", ADDRESS)
except UnknownChainError as exc:
print("refused, not seeded:", exc)
# ...and neither does a duplicate: the id is DERIVED from the chain and the
# normalised (lowercased) address, so the clash is known before any request.
try:
user.connect_address(CHAIN, ADDRESS)
except ConflictError as exc:
print("duplicate:", exc, "| existing_id:", exc.existing_id)
assert transport.calls == calls, "rejections cost zero requests"
print(connection)
refused pre-flight: not a canonical eip155 CAIP-2: 'ethereum'
refused, not seeded: unknown chain 'eip155:42161': CAIP-2 is the only key
duplicate: connection already exists: 'conn_d0327e21d9b0ea55' | existing_id: conn_d0327e21d9b0ea55
ConnectionRecord(id='conn_d0327e21d9b0ea55', chain_id='eip155:1', address='0x1111111111111111111111111111111111111111', created_at_ms=1754000000000)
Sync on the host's tick, inside the host's budget
sync(budget=N) spends at most N page requests, then stops and tells
you where it got to. Two cursors advance independently:
- live, forward from the head, so new activity is never starved;
- backfill, backwards through history, resumable.
A budget cut never advances the live cursor past unprocessed work, so a
resumed sync cannot skip a page. The host decides when the next tick
happens; the library only decides how much it may spend.
before = transport.calls
first = auradefi.sync(budget=2)
assert first.no_op is False
assert (first.pages_fetched, first.live_pages, first.backfill_pages) == (2, 1, 1)
# 3, not 4: the first backfill page deliberately OVERLAPS the anchor's
# lowest block, because the anchor page may have cut that block in half
# and nothing can know whether it did. One redelivered row buys never
# losing the remainder of a split block; it adds no event.
assert first.transactions_ingested == 3
assert transport.calls == before + 2 # budget respected exactly
row = first.connections[0]
assert (row.live_cursor, row.backfill_cursor, row.backfill_complete) == (106, 104, False)
print(first)
SyncReport(no_op=False, pages_fetched=2, live_pages=1, backfill_pages=1, transactions_ingested=3, connections=(ConnectionSyncReport(connection_id='conn_d0327e21d9b0ea55', no_op=False, pages_fetched=2, live_pages=1, backfill_pages=1, transactions_ingested=3, live_cursor=106, backfill_cursor=104, backfill_complete=False, failed=False),))
The second sync is a no-op: proven by counting
settings.sync_min_interval_s throttles the connection. An immediate
re-sync must make zero requests: not "fewer", not "cached". Zero. The
The contract is a request count, so that is what is asserted.
calls = transport.calls
noop = auradefi.sync(budget=2)
assert noop.no_op is True
assert noop.pages_fetched == 0 and noop.transactions_ingested == 0
assert transport.calls == calls # not one byte left the process
print(f"no-op sync: {noop.pages_fetched} pages, transport calls still {transport.calls}")
clock.advance(INTERVAL_MS) # the host's clock, the host's tick
# budget 4: confirming a window DRAINED costs one page. The six rows of
# [0, 105] fill pages 1-3 exactly, so the only evidence nothing remains
# is a fourth, short page.
resumed = auradefi.sync(budget=4)
assert (resumed.pages_fetched, resumed.live_pages, resumed.backfill_pages) == (4, 1, 3)
assert resumed.connections[0].backfill_complete is True
assert resumed.connections[0].backfill_cursor == 100
print("after advancing the clock:", resumed.pages_fetched, "pages, history complete:",
resumed.connections[0].backfill_complete)
no-op sync: 0 pages, transport calls still 3
after advancing the clock: 4 pages, history complete: True
A restart resumes stored work, it does not report "nothing to do"
Durable cursors are only half of it: the facade must also learn which
connections exist from the store rather than from its own memory. A fresh
Auradefi bound over the same SyncStatePort, a new process, as far as the
library can tell, enumerates the connection its predecessor stored, at
zero requests. Before 0.1.1 it enumerated an empty in-process list and
called that SyncReport(no_op=True): success-shaped, ingesting nothing,
forever (docs/internal/RELEASE_0.1.1.md §5 #21).
tenants() is the fifth SyncStatePort method that makes this possible, and
a store missing it is refused at bind time: the whole point being that
this failure must never be discoverable only as silence.
restarted = Auradefi(
ledger,
source,
Inquirer([DefiLlamaOracle(client)]),
clock,
Settings(sync_min_interval_s=60),
sync_state=state, # the ONLY thing carried across the "restart"
sync_page_size=PAGE_SIZE,
)
assert state.tenants() == (user.tenant_id,)
calls = transport.calls
after_restart = restarted.sync(budget=2)
assert transport.calls == calls, "still inside the throttle window: zero requests"
# The proof is the breakdown, not the aggregate: the restarted facade NAMED
# the stored connection. A no_op with no rows at all is the old defect.
assert [row.connection_id for row in after_restart.connections] == [connection.id]
assert after_restart.failed_connections == ()
print("restarted facade saw", len(after_restart.connections), "stored connection(s):",
after_restart.connections[0].connection_id)
class HalfAState:
"A 0.1.0-shaped store: four methods, no tenants(): refused at bind."
def get_state(self, tenant_id, connection_id): ...
def put_state(self, tenant_id, connection_id, state): ...
def add_connection(self, tenant_id, connection): ...
def connections(self, tenant_id): return ()
try:
Auradefi(ledger, source, Inquirer([]), clock, sync_state=HalfAState())
except ValidationError as exc:
print("stale port refused at bind:", exc)
restarted facade saw 1 stored connection(s): conn_d0327e21d9b0ea55
stale port refused at bind: sync_state has no 'tenants': see SyncStatePort
The host reads its rows back through its own session
This is the storage-is-a-port proof: the rows auradefi wrote are ordinary
rows in the host's database, queried with the host's own SQLModel session
and the host's own select. No library API is involved in the read.
with session_factory() as session:
rows = list(session.exec(
select(LedgerTransactionRow)
.where(LedgerTransactionRow.tenant_id == user.tenant_id)
.order_by(LedgerTransactionRow.block_number)
))
assert len(rows) == 7
assert [row.block_number for row in rows] == [100, 101, 102, 103, 104, 105, 106]
assert {row.account_id for row in rows} == {connection.id}
assert not any(row.removed for row in rows)
assert rows[0].id == "txn_b3618169bbd2dd6b"
assert rows[0].initiated_at == 1_700_000_000_000
for row in rows[:3]:
print(f"{row.id} block={row.block_number} entries={row.entries_json}")
print("...", len(rows), "rows in the host's sqlite")
txn_b3618169bbd2dd6b block=100 entries=[{"asset_id":"eip155:1/slip44:60","decimals":18,"direction":"in","raw":"1000000000000000000"}]
txn_66000dfdcbfd1e1a block=101 entries=[{"asset_id":"eip155:1/slip44:60","decimals":18,"direction":"in","raw":"1000000000000000000"}]
txn_ae616c0eebd4f70c block=102 entries=[{"asset_id":"eip155:1/slip44:60","decimals":18,"direction":"in","raw":"1000000000000000000"}]
... 7 rows in the host's sqlite
Reading back out: holdings and the scalar projection
holdings() prices every connection; scalar_metrics() projects a report
plus its transactions into 26 named floats. Portfolio value,
transaction count, and a 24-bucket hour-of-day histogram. That shape is
deliberately boring: it is what a downstream model or dashboard consumes
without knowing anything about crypto.
float here is a display/analytics contract, not a money contract: the
exact values remain Decimal on the report itself.
from decimal import Decimal
from auradefi.money.fiat import Money
(report,) = auradefi.holdings()
assert report.total_value == Money(Decimal("5025"), "USD") # 2 ETH @ 2500 + 25 USDC @ 1
assert [holding.symbol for holding in report.holdings] == ["ETH", "USDC"]
assert report.unpriced == ()
print(report.address, report.total_value)
metrics = auradefi.scalar_metrics()
values = {metric.name: metric.value for metric in metrics}
assert len(metrics) == 26
assert values["portfolio_value_usd"] == 5025.0
assert values["transaction_count"] == 7.0
assert sum(values[f"tx_count_hour_{hour:02d}"] for hour in range(24)) == 7.0
active = {hour for hour in range(24) if values[f"tx_count_hour_{hour:02d}"]}
assert active == {22, 23, 0, 1, 2, 3, 4}
print("metrics:", len(metrics), "| active UTC hours:", sorted(active))
0x1111111111111111111111111111111111111111 5025.000000000000000000 USD
metrics: 26 | active UTC hours: [0, 1, 2, 3, 4, 22, 23]
What embedding does not give you
- Backends: memory and SQLModel/sqlite are what ship. Postgres will
work through the same
LedgerPort, but only sqlite is exercised in CI.
- The default decoder ingests the native txlist stream only; token
transfers ride in behind the same
decoder= seam, which the host may
replace wholesale.
- Sync state defaults to an in-process
MemorySyncState; a host that wants
durable cursors across restarts binds its own SyncStatePort.
- One tenant per
external_user_id, single-process; there is no scheduler
and no background worker. The host owns the tick.
Next: 10_bitcoin_solana. The chains that are
not EVM.