auradefi 0.1.2
PyPI GitHub

PyBook 13: An EVM node of your own

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

keccak256 is not sha3_256

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

The static codec, and what it refuses

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'

A stand-in node

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 id

EvmRpc 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 speak

EvmContractReader.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

A shipped adapter, over the node

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 trip

Multicall3'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 out

scan_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

What this does not prove

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.