auradefi 0.1.2
PyPI GitHub

How do I read a contract straight from a node, with no aggregator?

Run it

pip install auradefi
python examples/12_read_a_contract_from_a_node.py

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 the package had no eth_call of its own. It has one now, in six pieces:

codec/keccak.py    keccak256, stdlib only, so no dependency was added
codec/abi.py       selectors, static words, and Multicall3's two shapes
rpc.py             JSON-RPC 2.0: single calls and an id-matched batch
multicall.py       Multicall3 aggregate3, one revert isolated to one call
logs.py            eth_getLogs, chunked over a block range, typed rows
reader.py          EvmContractReader: the seam the adapters already speak

This file drives all six against a stand-in node built with httpx.MockTransport, so it runs offline. Pointing it at a real chain is the URL on the line marked NODE_URL, and nothing else. The answers below are the integers this project's golden vectors pin at block 20,450,000, packed into words by hand. No recording in this repository came from a real node, so what runs here proves the path and not the chain state.

Four properties worth watching for, because each is a defect somewhere else:

What it prints

keccak256(b'') = c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
balanceOf(address) selector = 0x70a08231

eth_call USDC.balanceOf at block 20450000: 0x00000000000000000000000000000000000000000000000000000002dfdc1c35
batch out of order, matched by id: head=20450000, USDC decimals=6

reader: USDC decimals=6, balance=12345678901
empty result declared: SourceError: decimals result did not decode: 1 types need exactly 32 bytes, got 0

rocket-pool: pos_ff2e449baab082ad redeems 2.8 ETH from 2.5 rETH at rate 1.12

aggregate3 of 5 in one eth_call: 4 answered, 1 declared failure

scan_logs over 2 blocks in 1-block chunks: 1 Transfer, block 20449999, removed=False

OK: selectors, one call, a batch, five in one, logs, and an adapter.

The whole file

12_read_a_contract_from_a_node.py on GitHub: self-contained, offline, asserts its own output.

"""How do I read a contract straight from a node, with no aggregator?

    pip install auradefi
    python examples/12_read_a_contract_from_a_node.py

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
the package had no `eth_call` of its own. It has one now, in six pieces:

    codec/keccak.py    keccak256, stdlib only, so no dependency was added
    codec/abi.py       selectors, static words, and Multicall3's two shapes
    rpc.py             JSON-RPC 2.0: single calls and an id-matched batch
    multicall.py       Multicall3 aggregate3, one revert isolated to one call
    logs.py            eth_getLogs, chunked over a block range, typed rows
    reader.py          EvmContractReader: the seam the adapters already speak

This file drives all six against a stand-in node built with
`httpx.MockTransport`, so it runs offline. Pointing it at a real chain is the
URL on the line marked NODE_URL, and nothing else. The answers below are the
integers this project's golden vectors pin at block 20,450,000, packed into
words by hand. No recording in this repository came from a real node, so what
runs here proves the path and not the chain state.

Four properties worth watching for, because each is a defect somewhere else:

* a reverting call in a batch of five costs you the one call, not the batch;
* a batch is matched by JSON-RPC `id`, so a node that answers out of order is
  still answered correctly;
* an empty or short result raises `SourceError` instead of decoding to zero,
  so a call to a non-contract address never reads as an empty wallet;
* the block pin lives on the reader, so a report at block N cannot silently
  mix in a read at head.
"""

from __future__ import annotations

import json

import httpx

from auradefi.errors import SourceError
from auradefi.positions.adapters.staking.liquid import RocketPoolAdapter
from auradefi.positions.protocol import DiscoveryContext, ResolveContext
from auradefi.sources.evm.codec.abi import (
    encode,
    function_signature,
    selector,
)
from auradefi.sources.evm.codec.keccak import keccak256
from auradefi.sources.evm.logs import scan_logs
from auradefi.sources.evm.multicall import MULTICALL3_ADDRESS, Call, Multicall3
from auradefi.sources.evm.reader import EvmContractReader
from auradefi.sources.evm.rpc import EvmRpc, block_tag

NODE_URL = "https://evm-node.invalid/rpc"   # <- your node goes here
CHAIN = "eip155:1"
BLOCK = 20_450_000
HOLDER = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"

USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
WETH = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
RETH = "0xae78736cd615f374d3085123a210448e74fc6393"
DEAD = "0x000000000000000000000000000000000000dead"

# The same integers the shipped golden vectors pin at this block.
RETH_BALANCE = 2_500_000_000_000_000_000        # 2.5 rETH
RETH_RATE = 1_120_000_000_000_000_000           # getExchangeRate: 1.12
RETH_REDEEMED = 2_800_000_000_000_000_000       # 2.5 * 1.12, exact
USDC_BALANCE = 12_345_678_901

TRANSFER_TOPIC = "0x" + keccak256(b"Transfer(address,address,uint256)").hex()


# ------------------------------------------------------- 1. keccak and selectors
# hashlib ships sha3_256, which is NOT keccak256: the padding byte differs, so
# every selector derived from it addresses a function that does not exist. The
# published empty-string vector is the cheapest way to know which one you have.
assert keccak256(b"").hex() == (
    "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"
)
assert selector("balanceOf(address)").hex() == "70a08231"
assert function_signature("getReserves", ()) == "getReserves()"
print("keccak256(b'') =", keccak256(b"").hex())
print("balanceOf(address) selector =", "0x" + selector("balanceOf(address)").hex())


# ------------------------------------------------------------- 2. a stand-in node
# A real node would answer these. Replace `client` with a plain httpx.Client
# and every line below this section is unchanged.
def _word(value: int) -> str:
    return f"{value:064x}"


def _aggregate3_return(results: list[tuple[bool, bytes]]) -> str:
    """Pack `(bool,bytes)[]` the way Multicall3 returns it."""
    elements = [
        _word(int(ok)) + _word(0x40) + _word(len(data)) + data.hex().ljust(
            64 * ((len(data) + 31) // 32), "0"
        )
        for ok, data 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)


#: (target, selector) -> the result hex the node sends back.
ANSWERS: dict[tuple[str, str], str] = {
    (USDC, "0x313ce567"): "0x" + _word(6),                 # decimals()
    (USDC, "0x70a08231"): "0x" + _word(USDC_BALANCE),      # balanceOf(address)
    (WETH, "0x313ce567"): "0x" + _word(18),
    (RETH, "0x70a08231"): "0x" + _word(RETH_BALANCE),
    (RETH, "0xe6aa216c"): "0x" + _word(RETH_RATE),         # getExchangeRate()
}

LOG_ROWS = [
    {
        "address": USDC,
        "topics": [TRANSFER_TOPIC, "0x" + _word(0), "0x" + _word(int(HOLDER, 16))],
        "data": "0x" + _word(USDC_BALANCE),
        "blockNumber": hex(BLOCK - 1),
        "transactionHash": "0x" + "11" * 32,
        "logIndex": "0x0",
    }
]


def node(request: httpx.Request) -> httpx.Response:
    """The whole fake node: eth_call, eth_getBalance, eth_blockNumber, logs."""
    body = json.loads(request.content)
    if isinstance(body, list):                     # a JSON-RPC batch, answered
        return httpx.Response(200, json=[          # in REVERSED id order
            {"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)})


def _answer(item: dict) -> object:
    method, params = item["method"], item["params"]
    if method == "eth_blockNumber":
        return hex(BLOCK)
    if method == "eth_getBalance":
        return hex(4_000_000_000_000_000_000)
    if method == "eth_getLogs":
        return LOG_ROWS if params[0]["fromBlock"] == block_tag(BLOCK - 1) else []
    to, data = params[0]["to"], params[0]["data"]
    if to == MULTICALL3_ADDRESS:
        return FIVE_RESULTS
    # "0x" is what a node returns for a call to an address holding no code,
    # and the reader raises on it rather than reading it as zero.
    return ANSWERS.get((to, data[:10]), "0x")


#: Four answers and one declared failure, in request order. The reverting leg
#: carries empty returndata: DECLARED, never a zero word substituted for it.
FIVE_RESULTS = _aggregate3_return([
    (True, bytes.fromhex(_word(6))),
    (True, bytes.fromhex(_word(RETH_BALANCE))),
    (True, bytes.fromhex(_word(18))),
    (False, b""),
    (True, bytes.fromhex(_word(RETH_RATE))),
])

client = httpx.Client(transport=httpx.MockTransport(node), base_url=NODE_URL)
rpc = EvmRpc(client, NODE_URL)


# ---------------------------------------------------------------- 3. the raw RPC
assert rpc.eth_block_number() == BLOCK
assert rpc.eth_get_balance(HOLDER, block_tag(BLOCK)) == 4_000_000_000_000_000_000
calldata = "0x" + (
    selector(function_signature("balanceOf", ("address",)))
    + encode(("address",), (HOLDER,))
).hex()
raw = rpc.eth_call(USDC, calldata, block_tag(BLOCK))
print(f"\neth_call USDC.balanceOf at block {BLOCK}: {raw}")
assert int(raw, 16) == USDC_BALANCE

# A batch of two, answered by the node in reversed id order and returned in
# REQUEST order regardless. Matching by array position is the defect here.
first, second = rpc.batch([
    ("eth_blockNumber", []),
    ("eth_call", [{"to": USDC, "data": "0x313ce567"}, block_tag(BLOCK)]),
])
assert int(first.result, 16) == BLOCK and int(second.result, 16) == 6
print(f"batch out of order, matched by id: head={int(first.result, 16)}, "
      f"USDC decimals={int(second.result, 16)}")


# ------------------------------------------------------------------- 4. the reader
# `EvmContractReader.call(address, fn, args)` IS the adapter seam. It resolves
# the ABI types from a registry keyed by function name, so a caller writes the
# name and never a signature string, and an unknown name with arguments is
# refused before any HTTP rather than guessed into a wrong selector.
reader = EvmContractReader(rpc, block_number=BLOCK)
assert reader.call(USDC, "decimals") == 6
assert reader.call(USDC, "balanceOf", (HOLDER,)) == USDC_BALANCE
print(f"\nreader: USDC decimals={reader.call(USDC, 'decimals')}, "
      f"balance={reader.call(USDC, 'balanceOf', (HOLDER,))}")

# A read the node cannot answer is a SourceError, never a zero. That matters
# most for an address that holds no contract at all: read as zero, an empty
# account reports as a balance of nothing owned rather than as a failed read.
try:
    reader.call(DEAD, "decimals")
    raise SystemExit("an empty result must not decode")
except SourceError as failure:
    print(f"empty result declared: {type(failure).__name__}: {failure}")


# -------------------------------------------- 5. a shipped adapter, over the node
# Nothing below knows a node exists. The adapter asks the seam; the seam is now
# a real one. These are the same numbers `tests/golden/test_positions_*.py`
# pin against hand-written fixtures.
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
assert underlying.quantity.raw == RETH_REDEEMED
print(f"\n{position.adapter_id}: {position.id} redeems "
      f"{underlying.quantity} ETH from 2.5 rETH at rate 1.12")


# ------------------------------------------------- 6. five reads, one round trip
# Multicall3's whole reason for existing here is `allowFailure`. One reverting
# call comes back declared, and its four neighbours keep their answers.
multicall = Multicall3(rpc)
results = multicall.aggregate3([
    Call(USDC, bytes.fromhex("313ce567")),
    Call(RETH, bytes.fromhex("70a08231") + encode(("address",), (HOLDER,))),
    Call(WETH, bytes.fromhex("313ce567")),
    Call(DEAD, bytes.fromhex("313ce567")),          # this one reverts
    Call(RETH, bytes.fromhex("e6aa216c")),
], block_number=BLOCK)
assert [result.success for result in results] == [True, True, True, False, True]
assert results[3].data == b""
assert int.from_bytes(results[1].data, "big") == RETH_BALANCE
print(f"\naggregate3 of 5 in one eth_call: "
      f"{sum(r.success for r in results)} answered, "
      f"{sum(not r.success for r in results)} declared failure")


# ----------------------------------------------------------------- 7. log scanning
# One eth_getLogs per chunk over an inclusive range, with typed rows out. The
# decode handlers in later releases read transfers through this.
records = scan_logs(
    rpc,
    from_block=BLOCK - 1,
    to_block=BLOCK,
    address=USDC,
    topics=[TRANSFER_TOPIC],
    chunk_blocks=1,
)
(record,) = records
assert record.address == USDC and record.topics[0] == TRANSFER_TOPIC
assert int.from_bytes(record.data, "big") == USDC_BALANCE
print(f"\nscan_logs over 2 blocks in 1-block chunks: {len(records)} Transfer, "
      f"block {record.block_number}, removed={record.removed}")

print("\nOK: selectors, one call, a batch, five in one, logs, and an adapter.")