auradefi 0.1.1
PyPI GitHub

PyBook 10: Bitcoin & Solana

What this notebook proves: "Bitcoin: xpub derivation, gap-limit scan, UTXO handling. Done when: one xpub produces the full derived balance set."

What this notebook proves: "Solana: SPL, Token-2022 (including ScaledUiAmount). Done when: SPL balances work offline against cassettes."

Two chains, two lessons the EVM never teaches:

Everything replays committed cassettes, no network, no keys.

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 xpub never leaves the box

sources/bitcoin/xpub.py is a from-scratch BIP32 public-derivation implementation (validated against the published BIP32 test vectors) with no httpx import and no reference to the network module at all: a test asserts that mechanically, on the AST, because "we promise not to send it" is not a security property.

The host binds the key into a closure and hands the scanner a residual derive(chain, start, count) callable. The scanner receives addresses and never sees the extended key.

import functools

from auradefi.sources.bitcoin.xpub import derive_addresses, parse_xpub

# BIP32 test vector 1 master public key.
XPUB = (
    "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoC"
    "u1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8"
)
parsed = parse_xpub(XPUB)
assert parsed.depth == 0 and len(parsed.chain_code) == 32 and len(parsed.pubkey) == 33
print("depth", parsed.depth, "| pubkey", parsed.pubkey.hex()[:20] + "…")

external = derive_addresses(XPUB, "p2wpkh", 0, 0, 3)   # chain 0 = receive
change = derive_addresses(XPUB, "p2wpkh", 1, 0, 1)     # chain 1 = change
assert external[0] == "bc1qp5wfcq48h6d63wyy9qz0awtpfqwwv4sma86mhz"
assert change[0] == "bc1q7zwtzcqsm3k43ha0ac7nl8cz0hqrhckywf6sew"
assert all(address.startswith("bc1q") for address in external + change)

derive = functools.partial(derive_addresses, XPUB, "p2wpkh")  # the key is now BOUND
for index, address in enumerate(external):
    print(f"m/0/{index}  {address}")
depth 0 | pubkey 0339a36013301597daef…
m/0/0  bc1qp5wfcq48h6d63wyy9qz0awtpfqwwv4sma86mhz
m/0/1  bc1qrfxr69jqnhwufxgkqgcdep9prq4j4vuw2wyg0v
m/0/2  bc1qhvd6suvqzjcu9pxjhrwhtrlj85ny3n2mqql5w4

The gap-limit scan: where does a wallet end?

BIP44's answer: keep deriving until 20 consecutive unused addresses appear. The stop rule is a fact about the request count, so the cassette pins it: exactly 44 recorded lookups.

Ask for one address more and the next URL is unrecorded, so CassetteMissError fires instead of a silently wrong balance. That is the point of recording the wire rather than mocking the function.

import httpx

from auradefi.sources.bitcoin.esplora import Esplora, scan
from auradefi.testing.cassettes import load

seen = []


def recording_client(cassette):
    def handler(request):
        seen.append(str(request.url))
        return cassette.handle(request)

    return httpx.Client(transport=httpx.MockTransport(handler))


result = scan(Esplora(recording_client(load(CASSETTES / "phase6_xpub.json"))), derive, gap=20)

assert len(seen) == 44
assert seen[:23] == ["https://blockstream.info/api/address/" + a
                     for a in derive_addresses(XPUB, "p2wpkh", 0, 0, 23)]
# Asserted against the recorded traffic rather than promised:
# no request carries a key, and the string "xpub" is nowhere in the file.
assert all(url.rsplit("/", 1)[-1].startswith("bc1") for url in seen)
assert "xpub" not in (CASSETTES / "phase6_xpub.json").read_text(encoding="utf-8")
print(len(seen), "address lookups, every one a bc1 address; the xpub appears in none of them")
44 address lookups, every one a bc1 address; the xpub appears in none of them

UTXO arithmetic: confirmed only, mempool ignored

A balance is funded_txo_sum − spent_txo_sum over chain stats. The mempool is deliberately excluded, so an unconfirmed transaction cannot move a reported balance. A used but swept address still appears, with a zero balance and its transaction count, because "this address is part of your wallet" is information a wallet needs even when it holds nothing.

from auradefi.money.quantity import Quantity

assert [(row.chain, row.index, row.balance_sats, row.tx_count) for row in result.addresses] == [
    (0, 0, 100_000_000, 3),
    (0, 1, 25_000, 1),     # the cassette also holds 7,777 sats in the mempool: ignored
    (0, 2, 0, 4),          # used but swept: still reported
    (1, 0, 999_000_000, 12),
]
assert result.total_sats == 1_099_025_000
assert result.total == Quantity(1_099_025_000, 8)
assert str(result.total) == "10.99025"
assert result.caip19 == "bip122:000000000019d6689c085ae165831e93/slip44:0"

for row in result.addresses:
    print(f"m/{row.chain}/{row.index}  {row.address}  {row.balance_sats:>11} sats  ({row.tx_count} tx)")
print(f"{'TOTAL':>10}  {'':38} {result.total_sats:>11} sats = {result.total} BTC")
m/0/0  bc1qp5wfcq48h6d63wyy9qz0awtpfqwwv4sma86mhz    100000000 sats  (3 tx)
m/0/1  bc1qrfxr69jqnhwufxgkqgcdep9prq4j4vuw2wyg0v        25000 sats  (1 tx)
m/0/2  bc1qhvd6suvqzjcu9pxjhrwhtrlj85ny3n2mqql5w4            0 sats  (4 tx)
m/1/0  bc1q7zwtzcqsm3k43ha0ac7nl8cz0hqrhckywf6sew    999000000 sats  (12 tx)
     TOTAL                                          1099025000 sats = 10.99025 BTC
from auradefi.errors import CassetteMissError

# gap=21 needs index 23 on the external chain, which was never recorded.
try:
    scan(Esplora(load(CASSETTES / "phase6_xpub.json").client()), derive, gap=21)
except CassetteMissError:
    print("gap=21 walks off the end of the recording: the 44-request count IS the stop rule")
gap=21 walks off the end of the recording: the 44-request count IS the stop rule

Solana: where raw / 10**decimals stops being true

The warning, made concrete. Three things happen in one balance call:

  1. native SOL: lamports at 9 decimals;
  2. SPL tokens: one getTokenAccountsByOwner per program; an owner can hold several accounts of the same mint, so they are summed by mint;
  3. Token-2022 lives under a different program id, so one call cannot return both sets. The balance path is pinned at two calls, TOKEN_PROGRAM then TOKEN_2022_PROGRAM.
import json

from auradefi.sources.solana.rpc import TOKEN_2022_PROGRAM, TOKEN_PROGRAM, SolanaBalances, SolanaRpc

ADDRESS = "9wFFyRfZBsuAha4YcuxcXLKwMxJR43S7fPfQLXMFxbAF"
posts = []
cassette = load(CASSETTES / "solana_balances.json")


def solana_client():
    def handler(request):
        posts.append(json.loads(request.content))
        return cassette.handle(request)

    return httpx.Client(transport=httpx.MockTransport(handler))


rpc = SolanaRpc(solana_client())
balances = SolanaBalances(rpc).balances(ADDRESS)

assert [post["method"] for post in posts] == [
    "getBalance", "getTokenAccountsByOwner", "getTokenAccountsByOwner",
]
assert [post["params"][1]["programId"] for post in posts[1:3]] == [TOKEN_PROGRAM, TOKEN_2022_PROGRAM]
for balance in balances:
    print(f"{str(balance.quantity):>12}  ui={balance.ui_amount_string:<6} scaled={balance.scaled_ui}  {balance.caip19}")
         3.5  ui=3.5    scaled=False  solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501
        1000  ui=1000   scaled=False  solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
           1  ui=2      scaled=True  solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:ScaLedUiAmountMint22222222222222222222222222

The identity break

The Token-2022 account holds raw 1,000,000,000 at 9 decimals, exactly 1 by the usual identity, while the node reports uiAmountString of "2", because the mint carries a ScaledUiAmount multiplier of 2.

Both are kept: the exact Quantity for arithmetic, the node's string for display, and scaled_ui=True to say out loud that they diverge. An implementation that recomputed the display from the raw value would be wrong by 2x and would look completely normal doing it.

native, usdc, t22 = balances

assert str(native.quantity) == "3.5" and native.mint is None            # 3.5 SOL
assert usdc.quantity == Quantity(1_000_000_000, 6)                      # 250M + 750M summed
assert usdc.ui_amount_string == "1000" and usdc.scaled_ui is False

assert str(t22.quantity) == "1"          # raw / 10**decimals
assert t22.ui_amount_string == "2"       # what the mint says it displays as
assert t22.scaled_ui is True             # ...and the divergence is FLAGGED
assert t22.caip19.endswith("/token:ScaLedUiAmountMint22222222222222222222222222")  # base58 case preserved

print(f"Token-2022: raw/10^d = {t22.quantity}, node says {t22.ui_amount_string}, scaled_ui={t22.scaled_ui}")
print("two token accounts of one mint summed:", usdc.quantity, "USDC")
Token-2022: raw/10^d = 1, node says 2, scaled_ui=True
two token accounts of one mint summed: 1000 USDC

Paging and failure

Signature history pages until a page comes back short; the cassette pins the exact stop (2 POSTs for a limit of 2). And every RPC failure shape, JSON-RPC error member, HTTP 429, a non-JSON body, an envelope with no result, surfaces as one SourceError, never as None or a partial list.

from auradefi.errors import SourceError, ValidationError

signatures = rpc.get_signatures(ADDRESS, limit=2)
assert [signature.signature for signature in signatures] == ["SigNewest1", "SigErr2", "SigLast3"]
assert signatures[1].failed is True          # err -> failed
assert signatures[2].block_time is None      # a null blockTime stays null
assert posts[3]["params"][1] == {"limit": 2}
assert posts[4]["params"][1] == {"limit": 2, "before": "SigErr2"}
assert len(posts) == 5
print("signatures:", [(s.signature, s.slot, s.failed) for s in signatures])

# A malformed address is refused BEFORE any HTTP.
attempted = []
guard = SolanaRpc(httpx.Client(transport=httpx.MockTransport(
    lambda request: attempted.append(request) or httpx.Response(200))))
try:
    SolanaBalances(guard).balances("not-base58-0OIl")
except ValidationError as exc:
    print("refused pre-flight:", exc)
assert attempted == []

errors = SolanaRpc(load(CASSETTES / "solana_rpc_errors.json").client())
try:
    errors.get_balance(ADDRESS)
except SourceError as exc:
    print("rpc error surfaces as SourceError:", exc)
signatures: [('SigNewest1', 250000200, False), ('SigErr2', 250000100, True), ('SigLast3', 250000000, False)]
refused pre-flight: Solana address must be 32..44 base58 chars: 'not-base58-0OIl'
rpc error surfaces as SourceError: solana rpc getBalance error: code=-32602 message='Invalid params: unable to parse pubkey'

Honest edges

Bitcoin

Solana

Absent entirely: Cosmos, and every EVM chain beyond the ones the registry seeds.

Next: 11_accounting. What all this cost you.