import auradefi print("auradefi", auradefi.__version__) assert auradefi.__version__ == "0.1.2"
auradefi 0.1.2
What this notebook proves: "every shipped position adapter resolves against a cassette-backed reader at block 20,450,000 and produces Positions equal to the ones the hand-written fixtures produce", the 0.2.0 phase-11 acceptance rule.
Until 0.2.0 every EVM read in this package went through the Etherscan V2
aggregator, and the DeFi adapters ran against hand-written fixtures because
there was no eth_call anywhere in the tree. This book walks the six modules
that changed that, offline, against a stand-in node built with
httpx.MockTransport:
| Module | What it owns |
|---|---|
sources/evm/codec/keccak.py |
keccak-f[1600] and keccak256, stdlib only |
sources/evm/codec/abi.py |
selectors, static words, Multicall3's two dynamic shapes |
sources/evm/rpc.py |
JSON-RPC 2.0: single calls and an id-matched batch |
sources/evm/multicall.py |
Multicall3 aggregate3, one revert isolated to one call |
sources/evm/logs.py |
eth_getLogs, chunked over a block range, typed rows |
sources/evm/reader.py |
EvmContractReader, the seam the adapters already speak |
Every cell asserts its own output. A notebook is a test.
import auradefi print("auradefi", auradefi.__version__) assert auradefi.__version__ == "0.1.2"
auradefi 0.1.2
hashlib ships sha3_256, and it is a different function: the padding byte
differs, so a selector derived from it addresses a function no contract has.
The rule for this release was no new third-party dependencies, so
keccak-f[1600] is in the package, about seventy lines with published vectors.
An Ethereum function selector is the first four bytes of the keccak256 of the
signature, and abi.selector is exactly that.
from auradefi.sources.evm.codec.abi import function_signature, selector from auradefi.sources.evm.codec.keccak import keccak256 # The published empty-string vector, which is how you know which hash you hold. assert keccak256(b"").hex() == ( "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" ) # The signature is built from the registry's argument types, so a caller never # writes one by hand and never gets the spacing wrong. assert function_signature("balanceOf", ("address",)) == "balanceOf(address)" assert selector("balanceOf(address)").hex() == "70a08231" assert selector("getReserves()").hex() == "0902f1ac" assert selector("getExchangeRate()").hex() == "e6aa216c" print("keccak256(b'') =", keccak256(b"").hex()) print("balanceOf(address) ->", "0x" + selector("balanceOf(address)").hex())
keccak256(b'') = c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470 balanceOf(address) -> 0x70a08231
Every value the shipped adapters read is a single 32-byte word or a fixed
sequence of them. So the codec implements uint<N>, int<N> in two's
complement, address and bool, and refuses everything else instead of
guessing. A codec that silently mis-encodes a type it does not support is the
defect class this project cuts releases over, so the refusal is the
interesting behaviour and it has its own tests.
from auradefi.errors import ValidationError from auradefi.sources.evm.codec.abi import decode, encode USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" # int24 comes back signed, which is what a Uniswap v3 tick below the current # price looks like. Read as unsigned it would be a number near 2**24. assert decode(("int24",), (-193320).to_bytes(32, "big", signed=True)) == (-193320,) # A fixed tuple decodes in one go, in declaration order. reserves = encode(("uint112", "uint112", "uint32"), (52_000_000_000_000, 14_500 * 10**18, 1_722_470_000)) assert decode(("uint112", "uint112", "uint32"), reserves)[2] == 1_722_470_000 for unsupported in ("string", "bytes", "uint256[]", "uint255"): try: encode((unsupported,), (1,)) raise AssertionError(f"{unsupported} should have been refused") except ValidationError as refusal: print(f"{unsupported:<12} refused: {refusal}")
string refused: unsupported ABI type: 'string' bytes refused: unsupported ABI type: 'bytes' uint256[] refused: unsupported ABI type: 'uint256[]' uint255 refused: integer width must be a multiple of 8 in 8..256: 'uint255'
Everything below talks to a fake node over httpx.MockTransport, so this book
runs with the network unplugged. Pointing it at a real chain is the URL passed
to EvmRpc and nothing else.
import json import httpx BLOCK = 20_450_000 HOLDER = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" RETH = "0xae78736cd615f374d3085123a210448e74fc6393" DEAD = "0x000000000000000000000000000000000000dead" RETH_BALANCE = 2_500_000_000_000_000_000 # 2.5 rETH, the golden vector's number RETH_RATE = 1_120_000_000_000_000_000 # getExchangeRate: 1.12 USDC_BALANCE = 12_345_678_901 def word(value: int) -> str: return f"{value:064x}" ANSWERS = { (USDC, "0x313ce567"): "0x" + word(6), (USDC, "0x70a08231"): "0x" + word(USDC_BALANCE), (RETH, "0x70a08231"): "0x" + word(RETH_BALANCE), (RETH, "0xe6aa216c"): "0x" + word(RETH_RATE), } TRANSFER = "0x" + keccak256(b"Transfer(address,address,uint256)").hex() LOG_ROWS = [{ "address": USDC, "topics": [TRANSFER, "0x" + word(0), "0x" + word(int(HOLDER, 16))], "data": "0x" + word(USDC_BALANCE), "blockNumber": hex(BLOCK), "transactionHash": "0x" + "11" * 32, "logIndex": "0x0", }] def aggregate3_return(results): """Pack `(bool,bytes)[]` the way Multicall3 returns it.""" elements = [ word(int(ok)) + word(0x40) + word(len(payload)) + payload.hex().ljust(64 * ((len(payload) + 31) // 32), "0") for ok, payload in results ] offsets, running = [], 32 * len(elements) for element in elements: offsets.append(word(running)) running += len(element) // 2 return "0x" + word(0x20) + word(len(elements)) + "".join(offsets + elements) MULTICALL_RESULT = aggregate3_return([ (True, bytes.fromhex(word(6))), (True, bytes.fromhex(word(RETH_BALANCE))), (False, b""), # this one reverted (True, bytes.fromhex(word(RETH_RATE))), ]) CALLS_SEEN = [] def answer(item): method, params = item["method"], item["params"] if method == "eth_blockNumber": return hex(BLOCK) if method == "eth_getLogs": return LOG_ROWS if params[0]["fromBlock"] == hex(BLOCK) else [] to, data = params[0]["to"], params[0]["data"] CALLS_SEEN.append((to, data[:10], params[1])) if to == "0xca11bde05977b3631167028862be2a173976ca11": return MULTICALL_RESULT # "0x" is what a node returns for a call to an address holding no code. return ANSWERS.get((to, data[:10]), "0x") def node(request): body = json.loads(request.content) if isinstance(body, list): # Answered in REVERSED id order, deliberately: a compliant node may. return httpx.Response(200, json=[ {"jsonrpc": "2.0", "id": item["id"], "result": answer(item)} for item in reversed(body) ]) return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1, "result": answer(body)}) client = httpx.Client(transport=httpx.MockTransport(node)) print("stand-in node ready; nothing here opens a socket")
stand-in node ready; nothing here opens a socket
rpc.py: one call, and a batch matched by idEvmRpc speaks JSON-RPC 2.0 and nothing else. The batch form posts an array
with ids 1..N and looks each answer up by its id, never by its position
in the response array, because a compliant node may answer a batch in any
order. The node above reverses every batch on purpose so that discipline is
visible here.
A batch item carrying an error member comes back as a declared
BatchResult failure. It does not raise, because one failed item must not
void the other four.
from auradefi.sources.evm.rpc import EvmRpc, block_tag rpc = EvmRpc(client, "https://evm-node.invalid/rpc") # block_tag maps None to "latest" and an int to its minimal hex, so a block # pin is never a decimal string on the wire. assert block_tag(None) == "latest" assert block_tag(BLOCK) == "0x1380ad0" assert rpc.eth_block_number() == BLOCK head, decimals = rpc.batch([ ("eth_blockNumber", []), ("eth_call", [{"to": USDC, "data": "0x313ce567"}, block_tag(BLOCK)]), ]) assert int(head.result, 16) == BLOCK assert int(decimals.result, 16) == 6 assert head.error is None print(f"answered in reversed id order, returned in request order: " f"head={int(head.result, 16)}, USDC decimals={int(decimals.result, 16)}")
answered in reversed id order, returned in request order: head=20450000, USDC decimals=6
reader.py: the seam the adapters already speakEvmContractReader.call(address, fn, args) is the ContractReader protocol
the position adapters have used since 0.1.0. It resolves the ABI types from a
registry keyed by function name, so a caller writes "balanceOf" and never a
signature string, and an unknown name called with arguments is refused before
any HTTP instead of being guessed into a selector for a function the contract
does not have.
The binding is structural. sources may not import positions, so
reader.py names that package nowhere and matches the protocol by shape. A
runtime_checkable isinstance in the mirrored test proves it.
Two behaviours to watch. The block pin lives on the reader, so a report at
block N cannot silently mix in a read at head. And an empty result raises
SourceError instead of decoding to zero: read as zero, a call to an address
holding no code would report as a balance of nothing owned.
from auradefi.errors import SourceError from auradefi.positions.protocol import ContractReader from auradefi.sources.evm.reader import SIGNATURES, EvmContractReader reader = EvmContractReader(rpc, block_number=BLOCK) # Structural binding, proven the only way the layer contract allows. assert isinstance(reader, ContractReader) assert reader.call(USDC, "decimals") == 6 assert reader.call(USDC, "balanceOf", (HOLDER,)) == USDC_BALANCE # Every read went out pinned at the block the reader was built with. assert {seen[2] for seen in CALLS_SEEN} == {block_tag(BLOCK)} try: reader.call(DEAD, "decimals") except SourceError as failure: print("empty result ->", type(failure).__name__, "::", failure) print(f"registry covers {len(SIGNATURES)} functions:", ", ".join(sorted(SIGNATURES)))
empty result -> SourceError :: decimals result did not decode: 1 types need exactly 32 bytes, got 0 registry covers 14 functions: allPairs, allPairsLength, balanceOf, decimals, getExchangeRate, getPool, getReserves, getUserAccountData, positions, slot0, token0, token1, tokenOfOwnerByIndex, totalSupply
Nothing in positions/ knows a node exists. It asks the seam, and the seam is
now a real one. The numbers below are the ones
tests/golden/test_positions_liquid_staking.py pins against a hand-written
fixture at this same block, so the recorded path and the fixture path agree.
from auradefi.positions.adapters.staking.liquid import RocketPoolAdapter from auradefi.positions.protocol import DiscoveryContext, ResolveContext CHAIN = "eip155:1" adapter = RocketPoolAdapter() contracts = adapter.discover(DiscoveryContext(chain_id=CHAIN, reader=reader)) (position,) = adapter.resolve( ResolveContext(chain_id=CHAIN, address=HOLDER, reader=reader, block_number=BLOCK), contracts, ) (underlying,) = position.underlyings # 2.5 rETH at 1.12 redeems for 2.8 ETH, exactly, in integer arithmetic. assert underlying.quantity.raw == 2_800_000_000_000_000_000 assert position.id == "pos_ff2e449baab082ad" print(f"{position.adapter_id}: {position.id}") print(f" {underlying.quantity} ETH redeemable from 2.5 rETH at rate 1.12")
rocket-pool: pos_ff2e449baab082ad 2.8 ETH redeemable from 2.5 rETH at rate 1.12
multicall.py: four reads and one revert, in one round tripMulticall3's aggregate3 exists here for allowFailure. A reverting call
comes back as a declared CallResult failure carrying whatever returndata it
had, byte for byte, and its neighbours keep their answers. No zero is
substituted for a failed call, which is rule 8 (incomplete data is declared)
applied to a batch.
An empty calls list issues zero requests, because a refresh whose batch came
out empty must cost no node call.
from auradefi.sources.evm.multicall import Call, Multicall3 multicall = Multicall3(rpc) before = len(CALLS_SEEN) results = multicall.aggregate3([ Call(USDC, bytes.fromhex("313ce567")), Call(RETH, bytes.fromhex("70a08231") + encode(("address",), (HOLDER,))), Call(DEAD, bytes.fromhex("313ce567")), # reverts Call(RETH, bytes.fromhex("e6aa216c")), ], block_number=BLOCK) assert [result.success for result in results] == [True, True, False, True] assert results[2].data == b"" # declared, not a zero word assert int.from_bytes(results[1].data, "big") == RETH_BALANCE assert len(CALLS_SEEN) - before == 1 # four reads, one eth_call assert multicall.aggregate3([]) == () assert len(CALLS_SEEN) - before == 1 # an empty batch costs nothing print(f"aggregate3 of 4 in {len(CALLS_SEEN) - before} eth_call: " f"{sum(r.success for r in results)} answered, " f"{sum(not r.success for r in results)} declared failure")
aggregate3 of 4 in 1 eth_call: 3 answered, 1 declared failure
logs.py: a block range, chunked, typed on the way outscan_logs walks an inclusive block range one chunk at a time and returns
typed LogRecord rows. Addresses and hashes come back lowercase so two
spellings of one contract never compare unequal, data is bytes, and
blockNumber and logIndex parse from hex with int(x, 16) and never
through a float.
There is deliberately no timestamp field: eth_getLogs returns no time, and a
half-populated one would be worse than its absence. The decode handlers in
later releases read transfers through this.
from auradefi.sources.evm.logs import scan_logs records = scan_logs( rpc, from_block=BLOCK - 1, to_block=BLOCK, address=USDC, topics=[TRANSFER], chunk_blocks=1, ) (record,) = records assert record.address == USDC assert record.topics[0] == TRANSFER assert int.from_bytes(record.data, "big") == USDC_BALANCE assert record.block_number == BLOCK and record.removed is False print(f"2 blocks in 1-block chunks -> {len(records)} Transfer at block " f"{record.block_number}, log_index {record.log_index}")
2 blocks in 1-block chunks -> 1 Transfer at block 20450000, log_index 0
The phase-11 golden vector, tests/golden/test_phase11_reader.py, replays a
hand-authored cassette. Its words were packed from the integers the
existing position goldens already pin, and its selectors were derived
independently and cross-checked. So it proves the selector derivation, the
word packing, the tuple decode, the block pin and the JSON-RPC path end to
end, and it proves the five shipped adapters produce byte-identical
Position objects through a node and through a dict.
It does not prove agreement with a real archive node, and neither does this book. Two of the pinned numbers could not come from one in any case: the Aave vector uses a fabricated holder from the spec's worked example, and the rETH exchange rate is protocol-global. Recording a genuine archive node at block 20,450,000 and re-pinning every golden to what it returns needs network and an archive node, and it is a separate human task.
examples/12_read_a_contract_from_a_node.py is the same material as one
runnable file.