pyrxd.network — ElectrumX + BTC sources

pyrxd.network — network layer for Radiant / Bitcoin SPV.

Re-exports the public surface of the sub-modules so callers can do:

from pyrxd.network import ElectrumXClient, ChainTracker, …

class pyrxd.network.BitcoinCoreRpcSource[source]

Bases: BtcDataSource

BtcDataSource backed by a Bitcoin Core JSON-RPC endpoint.

Credentials are stored as SecretBytes and never logged.

Parameters:
  • url – RPC endpoint URL, e.g. http://localhost:8332/.

  • user – RPC username.

  • password – RPC password (stored securely as SecretBytes).

__init__(url, user, password)[source]
Parameters:
Return type:

None

async close()[source]

Close any underlying connections held by this source.

Return type:

None

async get_block_hash(height)[source]

Return the 32-byte block hash at height.

Parameters:

height (BlockHeight)

Return type:

Hex32

async get_block_header_hex(height)[source]

Return the raw 80-byte block header at height.

Parameters:

height (BlockHeight)

Return type:

bytes

async get_header_chain(start_height, count)[source]

Return count consecutive 80-byte headers starting at start_height.

Parameters:
Return type:

list[bytes]

async get_merkle_proof(txid, height)[source]

Return (branch_hashes_hex, leaf_position) for txid at height.

Parameters:
Return type:

tuple[list[str], int]

async get_raw_tx(txid, min_confirmations=6)[source]

Return raw transaction bytes, enforcing min_confirmations.

Parameters:
  • txid (Txid)

  • min_confirmations (int)

Return type:

RawTx

async get_tip_height()[source]

Return the current chain tip block height.

Return type:

BlockHeight

async get_tx_block_height(txid)[source]

Return the block height at which txid was confirmed.

Raises NetworkError if the transaction is unconfirmed or not found.

Parameters:

txid (Txid)

Return type:

BlockHeight

async get_tx_output_script_type(txid, output_index)[source]

Return the output script type: p2pkh, p2wpkh, p2sh, p2tr, or unknown.

Parameters:
Return type:

str

class pyrxd.network.BlockstreamSource[source]

Bases: BtcDataSource

BtcDataSource backed by the blockstream.info HTTP API.

__init__(base_url='https://blockstream.info/api')[source]
Parameters:

base_url (str)

Return type:

None

async close()[source]

Close any underlying connections held by this source.

Return type:

None

async get_block_hash(height)[source]

Return the 32-byte block hash at height.

Parameters:

height (BlockHeight)

Return type:

Hex32

async get_block_header_hex(height)[source]

Return the raw 80-byte block header at height.

Parameters:

height (BlockHeight)

Return type:

bytes

async get_header_chain(start_height, count)[source]

Return count consecutive 80-byte headers starting at start_height.

Parameters:
Return type:

list[bytes]

async get_merkle_proof(txid, height)[source]

Return (branch_hashes_hex, leaf_position) for txid at height.

Parameters:
Return type:

tuple[list[str], int]

async get_raw_tx(txid, min_confirmations=6)[source]

Return raw transaction bytes, enforcing min_confirmations.

Parameters:
  • txid (Txid)

  • min_confirmations (int)

Return type:

RawTx

async get_tip_height()[source]

Return the current chain tip block height.

Return type:

BlockHeight

async get_tx_block_height(txid)[source]

Return the block height at which txid was confirmed.

Raises NetworkError if the transaction is unconfirmed or not found.

Parameters:

txid (Txid)

Return type:

BlockHeight

async get_tx_output_script_type(txid, output_index)[source]

Return the output script type: p2pkh, p2wpkh, p2sh, p2tr, or unknown.

Parameters:
Return type:

str

class pyrxd.network.BtcDataSource[source]

Bases: ABC

Abstract interface for blockchain data providers.

abstractmethod async close()[source]

Close any underlying connections held by this source.

Return type:

None

abstractmethod async get_block_hash(height)[source]

Return the 32-byte block hash at height.

Parameters:

height (BlockHeight)

Return type:

Hex32

abstractmethod async get_block_header_hex(height)[source]

Return the raw 80-byte block header at height.

Parameters:

height (BlockHeight)

Return type:

bytes

abstractmethod async get_header_chain(start_height, count)[source]

Return count consecutive 80-byte headers starting at start_height.

Parameters:
Return type:

list[bytes]

abstractmethod async get_merkle_proof(txid, height)[source]

Return (branch_hashes_hex, leaf_position) for txid at height.

Parameters:
Return type:

tuple[list[str], int]

abstractmethod async get_raw_tx(txid, min_confirmations=6)[source]

Return raw transaction bytes, enforcing min_confirmations.

Parameters:
  • txid (Txid)

  • min_confirmations (int)

Return type:

RawTx

abstractmethod async get_tip_height()[source]

Return the current chain tip block height.

Return type:

BlockHeight

abstractmethod async get_tx_block_height(txid)[source]

Return the block height at which txid was confirmed.

Raises NetworkError if the transaction is unconfirmed or not found.

Parameters:

txid (Txid)

Return type:

BlockHeight

abstractmethod async get_tx_output_script_type(txid, output_index)[source]

Return the output script type: p2pkh, p2wpkh, p2sh, p2tr, or unknown.

Parameters:
Return type:

str

class pyrxd.network.ChainTracker[source]

Bases: object

Verifies Merkle inclusion proofs against confirmed block headers.

Bitcoin block header layout (80 bytes, all fields little-endian):
  • version : 4 bytes [0:4]

  • prev_hash : 32 bytes [4:36]

  • merkle_root : 32 bytes [36:68] ← compared here

  • time : 4 bytes [68:72]

  • bits : 4 bytes [72:76]

  • nonce : 4 bytes [76:80]

The merkle_root in the header is stored in little-endian byte order, matching the convention used by MerklePath.compute_root().

__init__(btc_source)[source]
Parameters:

btc_source (BtcDataSource)

Return type:

None

async is_valid_root(merkle_root, height)[source]

Fetch the block header at height and check its Merkle root.

Parameters:
  • merkle_root (Hex32) – The 32-byte Merkle root to verify (as Hex32).

  • height (BlockHeight) – Block height of the header to check against.

Returns:

True if the header’s Merkle root matches merkle_root.

Return type:

bool

async is_valid_root_for_height(root_hex, height)[source]

Convenience wrapper accepting hex string root and plain int height.

This matches the signature expected by MerklePath.verify().

Parameters:
  • root_hex (str) – 64-char lowercase hex string (big-endian display order, as returned by MerklePath.compute_root()).

  • height (int) – Block height as a plain int.

Return type:

bool

class pyrxd.network.ElectrumXClient[source]

Bases: object

Async ElectrumX JSON-RPC client.

Parameters:
  • urls – One or more ElectrumX server URLs. The client uses the first URL; on disconnect it attempts one reconnect, then raises NetworkError.

  • allow_insecure – If False (default) ws:// URLs raise NetworkError immediately. Set to True only for local testing.

  • timeout – Per-request timeout in seconds (default 30).

  • spki_pins – Optional TLS SubjectPublicKeyInfo pins (sha256/<base64>). Empty (the default) leaves pinning OFF — ordinary CA validation only. When supplied, every connection is checked against the set before any RPC is sent and a mismatch raises TlsPinMismatchError. See pyrxd.network.tls_pin for the format and for why it is opt-in.

__init__(urls, *, allow_insecure=False, timeout=30.0, spki_pins=())[source]
Parameters:
Return type:

None

async assert_chain(expected_genesis_hash)[source]

Fail closed unless the server is on the chain identified by expected_genesis_hash.

Mirrors pyrxd.eth_wallet.rpc.EthRpc.assert_chain() — the ETH leg has refused to act on a wrong-chain endpoint since it shipped, and an ElectrumX URL carries even less information about which chain is behind it than an RPC URL does. Reads block 0 (blockchain.block.header [0], one round trip, no state) and compares the Radiant double-SHA-512/256 header hash against the expected value.

This is what turns a declared network binding into a verified one: without it, --network regtest pointed at a mainnet server is indistinguishable from a correct setup until a transaction lands on the wrong chain.

Parameters:

expected_genesis_hash (str) – Genesis block hash in display order — see pyrxd.network.registry.GENESIS_BLOCK_HASHES.

Returns:

The observed genesis hash (equal to the expected one on success).

Return type:

str

Raises:
  • ValidationError – If the server’s genesis hash differs from the expected one. Both values are public chain data, so both are named verbatim — that is what makes the misconfiguration fixable.

  • NetworkError – On transport failure or a malformed header response.

async broadcast(raw_tx)[source]

Broadcast a raw transaction to the network.

Parameters:

raw_tx (bytes) – Serialised transaction bytes.

Returns:

The transaction id returned by the server.

Return type:

Txid

async call_extension(method, params=None)[source]

Call an arbitrary JSON-RPC method on the connected server.

Use this for indexer-extension RPCs that aren’t part of the base ElectrumX surface — e.g. RXinDexer’s wave.resolve, glyph.get_token, swap.get_unconfirmed_orders. The underlying transport (connection, id correlation, error handling) is identical to the built-in methods.

Returns the raw result field from the JSON-RPC response. Server errors raise NetworkError. The caller is responsible for validating the result shape.

Parameters:
Return type:

Any

async close()[source]

Close the underlying WebSocket connection.

Cancels the reader task, fails any in-flight requests with NetworkError, and closes the socket.

Return type:

None

async get_balance(script_hash)[source]

Return the confirmed and unconfirmed balance for script_hash, in photons.

The script_hash is sha256(locking_script) with bytes reversed (ElectrumX little-endian convention). Accepts Hex32, raw bytes (length 32), or a hex str (length 64).

Returns:

(confirmed, unconfirmed)

Return type:

tuple[Photons, Photons]

Parameters:

script_hash (Hex32 | bytes | str)

async get_block_header(height)[source]

Return the raw 80-byte block header at height.

Parameters:

height (BlockHeight)

Return type:

bytes

async get_history(script_hash)[source]

Return the transaction history for script_hash.

Returns a list of {"tx_hash": str, "height": int} dicts. Unconfirmed transactions have height of 0 or negative.

Parameters:

script_hash (Hex32 | bytes | str)

Return type:

list[dict[str, Any]]

async get_tip_height()[source]

Return the current chain tip block height.

Uses blockchain.headers.subscribe, whose INITIAL response is the current tip header — {"height": N, "hex": "..."} (standard ElectrumX). The call also installs a server-side header-push subscription, but that is harmless here: the reader loop drops every id-less server push (see _reader_loop()), so later header notifications never interfere with request/response matching.

(The prior implementation called blockchain.block.header [0, 0] expecting a {"height", ...} dict, but standard ElectrumX returns the bare genesis-header hex string for that call — so the tip read raised “Unexpected response type” against real servers, e.g. electrumx.radiant4people.com.)

Return type:

BlockHeight

async get_transaction(txid)[source]

Fetch the raw transaction bytes for txid.

Returns:

The serialised transaction (> 64 bytes, Merkle-forgery safe).

Return type:

RawTx

Parameters:

txid (Txid)

async get_transaction_merkle(txid, height)[source]

Fetch the Merkle proof for txid at block height.

Returns:

A parsed Merkle path object.

Return type:

MerklePath

Parameters:
async get_transaction_verbose(txid)[source]

Fetch the verbose JSON-decoded form of a transaction.

Calls blockchain.transaction.get with verbose=True and returns the dict the server provides — including confirmations, blockhash, blocktime. Used by confirmation polling.

Distinct from get_transaction() (which returns raw bytes for cryptographic operations like merkle-proof checks). Callers polling for “is this tx confirmed yet?” want THIS one.

Bound to the request the same way get_transaction() is. The raw form recomputes hash256(raw); the verbose form has no bytes to hash, so it binds the txid the node echoes (getrawtransaction <txid> true always returns it — Radiant-Core src/rpc/rawtransaction.cpp, and ElectrumX’s blockchain.transaction.get passes the daemon object through verbatim). Without this the ONLY untethered transaction read in the client was the one every confirmation gate is built on (pyrxd.network.confirm.wait_for_confirmation(), pyrxd.gravity.radiant_leg.RadiantCovenantLeg.confirmations(), pyrxd.gravity.watch.adapters.ElectrumRxdChainSource): a server could answer with a DIFFERENT, deeply-buried transaction’s body and satisfy the depth threshold without fabricating a single field — it just returns a true answer to a question nobody asked.

Parameters:

txid (Txid)

Return type:

dict[str, Any]

async get_utxos(script_hash)[source]

Return the list of UTXOs for script_hash.

Accepts Hex32, raw bytes (length 32), or a hex str (length 64). Each UTXO is returned as a typed UtxoRecord.

Parameters:

script_hash (Hex32 | bytes | str)

Return type:

list[UtxoRecord]

class pyrxd.network.Endpoint[source]

Bases: object

One ElectrumX server, with the trust decisions that apply to it.

url

wss:// (or ws:// with allow_insecure) WebSocket URL.

Type:

str

allow_insecure

Permit a plaintext ws:// URL. Needed for a local regtest indexer; never appropriate for a public endpoint.

Type:

bool

spki_pins

Optional TLS SubjectPublicKeyInfo pins (sha256/<base64>). Empty (the default) means pinning is OFF for this endpoint. See pyrxd.network.tls_pin for why that is the default.

Type:

tuple[str, …]

__init__(url, allow_insecure=False, spki_pins=())
Parameters:
Return type:

None

allow_insecure: bool = False
property key: str

Normalised identity used for de-duplication (case + trailing slash).

spki_pins: tuple[str, ...] = ()
url: str
class pyrxd.network.FailoverElectrumXClient[source]

Bases: object

An ElectrumXClient-shaped facade that fails over between endpoints.

Drop-in for the read/broadcast surface the SDK and CLI actually use, so callers written against ElectrumXClient keep working unchanged.

Parameters:
  • profile – The network’s endpoint list (preference-ordered) plus its expected genesis hash. Build one with NetworkProfile.build().

  • timeout – Per-request timeout handed to each underlying client (default 30s). Note the worst case for one logical call is timeout * len(endpoints).

  • client_factory – Injected seam: Endpoint -> ElectrumXClient. Tests pass fakes; production leaves it None and gets real clients.

  • verify_chain – Verify each endpoint’s genesis hash on first use (default True). Only turn this off for a chain pyrxd has no constant for — and then you are trusting the URL, which is what got us here. Leaving it True for a profile that carries no genesis hash is a construction error and raises: the check must never silently become a no-op.

Notes

A single-endpoint profile is supported and degenerates to plain ElectrumXClient behaviour: nothing to fail over to, so a transport error surfaces to the caller exactly as before (plus the one-time chain check). Configuring exactly one endpoint therefore remains the documented way to get the old, no-failover behaviour.

__init__(profile, *, timeout=30.0, client_factory=None, verify_chain=True)[source]
Parameters:
Return type:

None

property active_url: str

The endpoint the next call will try first.

async assert_chain(expected_genesis_hash)[source]

Verify the active endpoint’s chain. Failover applies (a dead endpoint is skipped).

Parameters:

expected_genesis_hash (str)

Return type:

str

async broadcast(raw_tx)[source]

Broadcast raw_tx, retrying the SAME BYTES on the next endpoint after a transport failure.

Why retrying a broadcast is safe here — and what makes it unsafe elsewhere

A broadcast is not a pure read, so “just retry it” deserves an argument rather than a shrug. Three properties make this specific retry safe:

  1. The bytes are captured once, before the first attempt. Every retry replays the identical serialised transaction. The dangerous version of this feature would take a builder callback and re-run it on failure: a rebuild can select different UTXOs, a different fee, or produce a different signature, and broadcasting a different transaction that spends the same inputs is a double-spend attempt, not a retry. Radiant has no RBF — src/validation.cpp:667 rejects any mempool conflict outright as txn-mempool-conflict — so a conflicting sibling cannot replace the first transaction, but it CAN be the one that gets mined and strand the transaction the caller believes it sent. Hence: bytes in, bytes out, never a rebuild. The signature is broadcast(raw_tx: bytes) precisely so this class cannot rebuild anything.

  2. The transaction id is a pure function of those bytes (SHA-256d(raw) reversed), so a retry cannot change the identity of what was sent. Whichever endpoint accepts it, the caller gets the same txid — there is no “which node’s answer do I believe?” question.

  3. Only transport failures trigger the retry. A PolicyRejection — the node evaluated the transaction and said no — is returned to the caller immediately. Asking a second node to accept a transaction the first one rejected is not resilience; it is looking for a node with laxer rules, and it would bury the reject reason that makes the failure diagnosable.

The failure this actually fixes is the lost response: the node accepted the transaction and the socket died before the reply arrived. Retrying then is not just safe, it is necessary — otherwise the caller reports failure for a transaction that is live on the network. When a later endpoint answers “I already have this” (txn-already-known / txn-already-in-mempool / already-in-chain), that is treated as success — but only after the claim is corroborated by a read (_holds_tx()): the endpoint must serve the same bytes back. An uncorroborated -27 is a claim any hostile or broken server can make for free, and honoring it reports a live transaction where there is none.

That last conversion applies only to a retry. On the very first attempt an “already known” rejection still raises, unchanged — there the caller is broadcasting something the network already has without any transport fault to explain it, which is information worth surfacing rather than swallowing.

Parameters:

raw_tx (bytes)

Return type:

Txid

async call_extension(method, params=None, *, idempotent=False)[source]

Call an indexer-extension RPC.

NOT retried unless idempotent is set. This method is an escape hatch onto arbitrary server methods; a failover layer has no way to know whether replaying some.extension.method is harmless. Defaulting to “retry” would be exactly the blind retry of a possibly-non-idempotent call that this module is careful to avoid. Pass idempotent=True for a read (glyph.get_token, wave.resolve, …).

Parameters:
Return type:

Any

async close()[source]

Close every underlying client. Safe to call more than once.

Return type:

None

async get_balance(script_hash)[source]
Parameters:

script_hash (Hex32 | bytes | str)

Return type:

tuple[Photons, Photons]

async get_block_header(height)[source]
Parameters:

height (BlockHeight)

Return type:

bytes

async get_history(script_hash)[source]
Parameters:

script_hash (Hex32 | bytes | str)

Return type:

list[dict]

async get_tip_height()[source]
Return type:

BlockHeight

async get_transaction(txid)[source]
Parameters:

txid (Txid)

Return type:

RawTx

async get_transaction_merkle(txid, height)[source]
Parameters:
Return type:

MerklePath

async get_transaction_verbose(txid)[source]
Parameters:

txid (Txid)

Return type:

dict[str, Any]

async get_utxos(script_hash)[source]
Parameters:

script_hash (Hex32 | bytes | str)

Return type:

list[UtxoRecord]

property profile: NetworkProfile
property urls: tuple[str, ...]

Endpoint URLs in current preference order.

class pyrxd.network.MempoolSpaceSource[source]

Bases: BtcDataSource

BtcDataSource backed by the mempool.space HTTP API.

Parameters:

base_url – Base URL of the API (default https://mempool.space/api).

__init__(base_url='https://mempool.space/api')[source]
Parameters:

base_url (str)

Return type:

None

async close()[source]

Close the underlying HTTP session.

Return type:

None

async get_block_hash(height)[source]

Return the 32-byte block hash at height.

Parameters:

height (BlockHeight)

Return type:

Hex32

async get_block_header_hex(height)[source]

Return the raw 80-byte block header at height.

Parameters:

height (BlockHeight)

Return type:

bytes

async get_header_chain(start_height, count)[source]

Return count consecutive 80-byte headers starting at start_height.

Parameters:
Return type:

list[bytes]

async get_merkle_proof(txid, height)[source]

Return (branch_hashes_hex, leaf_position) for txid at height.

Parameters:
Return type:

tuple[list[str], int]

async get_raw_tx(txid, min_confirmations=6)[source]

Return raw transaction bytes, enforcing min_confirmations.

Parameters:
  • txid (Txid)

  • min_confirmations (int)

Return type:

RawTx

async get_tip_height()[source]

Return the current chain tip block height.

Return type:

BlockHeight

async get_tx_block_height(txid)[source]

Return the block height at which txid was confirmed.

Raises NetworkError if the transaction is unconfirmed or not found.

Parameters:

txid (Txid)

Return type:

BlockHeight

async get_tx_output_script_type(txid, output_index)[source]

Return the output script type: p2pkh, p2wpkh, p2sh, p2tr, or unknown.

Parameters:
Return type:

str

class pyrxd.network.MultiSourceBtcDataSource[source]

Bases: BtcDataSource

A quorum-based composite data source.

For read operations, all sources are queried concurrently and the result is returned only if at least quorum sources agree. For broadcast-style operations, sources are tried in order until one succeeds.

Parameters:
  • sources – Two or more BtcDataSource instances.

  • quorum – Minimum number of agreeing sources required (default 2).

__init__(sources, quorum=2)[source]
Parameters:
Return type:

None

async close()[source]

Close all underlying sources.

Return type:

None

async get_block_hash(height)[source]

Return the 32-byte block hash at height.

Parameters:

height (BlockHeight)

Return type:

Hex32

async get_block_header_hex(height)[source]

Return the raw 80-byte block header at height.

Parameters:

height (BlockHeight)

Return type:

bytes

async get_header_chain(start_height, count)[source]

Return count consecutive 80-byte headers starting at start_height.

Parameters:
Return type:

list[bytes]

async get_merkle_proof(txid, height)[source]

Return (branch_hashes_hex, leaf_position) for txid at height.

Parameters:
Return type:

tuple[list[str], int]

async get_raw_tx(txid, min_confirmations=6)[source]

Return raw transaction bytes, enforcing min_confirmations.

Parameters:
  • txid (Txid)

  • min_confirmations (int)

Return type:

RawTx

async get_tip_height()[source]

Highest tip a strict MAJORITY of the responding sources corroborate (never fewer than quorum of them).

Tip height is the ONE value in this interface that honest, non-malicious sources on the same chain legitimately disagree about: a block takes time to propagate, so at any moment some endpoints are one (occasionally two) blocks ahead of the others. Routing it through _require_quorum(), which demands an EXACT match, therefore refused a completely ordinary reading — [900000, 900001] at quorum=1, or an even 2/2 split at quorum=2 — and a refusal here is not a safe default. The caller is a confirmation wait on a chain with neither RBF nor CPFP (gravity.trade), so an abort during a timelock race costs the funds the check was protecting. A guard that refuses valid work is its own fund-safety bug.

Why dropping the exact-match rule is safe here, and what replaces it. Heights are monotone and cumulative: a source reporting tip H asserts “the chain has reached at least H”, which implies every weaker claim tip >= h for h <= H. Corroboration therefore does not need identical replies — the count backing a candidate H is simply how many sources reported at least H. Sorting the answers descending, the value at index r - 1 is the largest H that r sources back. Set r to a strict majority of the sources that answered, floored at quorum:

  • Inflation is refused. A minority reporting a high tip sits above index r - 1 and never moves the answer — and it is discarded silently, with no DoS. This is the attack that matters, because an inflated tip overstates confirmation depth and can talk a caller into treating a shallow or absent transaction as buried. Two colluding sources out of five lose to the three honest ones (the same majority rule _require_quorum() applies to exact-match values), rather than winning by being the quorum-th answer.

  • Deflation is refused too — which a plain min() across all sources would NOT be. One stuck or lying source reporting height 0 would drag min() to 0 forever and stall every confirmation wait behind it. A minority low answer sits below index r - 1 and is discarded just the same. (This is the one property the old exact-match gate did have, and losing it would have traded one refusal bug for another.)

  • Honest skew is tolerated, because a one-block spread is no longer a disagreement to adjudicate. It just means the corroborated lower bound is the older block — the same “only as buried as the most pessimistic source” conservatism as MultiSourceBtcFundingReader.confirmations(), applied to whichever block the majority has actually seen.

The majority is over the CONFIGURED sources, not the ones that answered. Counting only responders let an attacker win by attrition rather than by argument: it does not have to out-vote an honest endpoint it can make unreachable, and a rate-limited public endpoint is indistinguishable from a suppressed one. quorum stays as the availability gate — that many sources must answer — but it no longer decides the value, because raising it above a majority used to make the result MORE deflatable, not less: at 5 sources with quorum=5 the answer was heights[4], the minimum, so one source reporting 0 returned 0. That is the exact min() failure this method was written to avoid, reappearing in the configuration an operator would choose for more assurance.

The cost, stated rather than buried. Discarding the lowest len(sources) - r readings is what makes a stuck or lying source unable to drag the tip down — but those same readings are where genuine pessimism lives. Three honest sources reporting 900_002 / 900_001 / 900_000 now yield 900_001, not the minimum 900_000, so a depth computed as tip - tx_height can read one block deeper than the most cautious source would say. That is the exchange: tolerate up to len(sources) - r malicious or stuck sources, and accept a tip up to that many propagation steps above the absolute floor. It cannot be had both ways — honouring the lowest reading is what let one source reporting 0 return 0. A caller wanting more conservatism should require more confirmations, which is the knob that exists for it.

Two further consequences worth knowing:

  • At len(sources) == 2 a majority is both of them, so one unreachable source fails the read. Two endpoints cannot tell a liar from a laggard. An operator who genuinely wants to trust a single source should configure ONE source, where the majority is that source and the read succeeds; configuring two is a request for cross-checking, and losing one means the cross-check cannot be performed.

  • quorum below the majority no longer buys availability, because the majority governs. It still governs _require_quorum()’s minimum group size.

Return type:

BlockHeight

async get_tx_block_height(txid)[source]

Return the block height at which txid was confirmed.

Raises NetworkError if the transaction is unconfirmed or not found.

Parameters:

txid (Txid)

Return type:

BlockHeight

async get_tx_output_script_type(txid, output_index)[source]

Return the output script type: p2pkh, p2wpkh, p2sh, p2tr, or unknown.

Parameters:
Return type:

str

class pyrxd.network.MultiSourceBtcFundingReader[source]

Bases: object

Quorum BtcFundingReader over N independent Esplora-style providers.

Audit 2026-05-29 F-17: mitigates the single-source confirmation-depth SPOF — a lone compromised/MITM’d source that OVER-reports depth (under-reports block_height) can make an unburied/reorgable tx look final and trigger a premature release.

Operator policy (decided 2026-05-29):
  • quorum = 2 of 3 providers (majority): tolerates one source down or lying.

  • dust_cap_sats = 10_000: at/below the cap a single successful read is accepted (the documented dust posture); ABOVE it the quorum is REQUIRED (fail-closed).

  • confirmations() returns the MINIMUM depth across responding sources — a tx is only as buried as the most-pessimistic source, defeating an over-reporter.

  • read_output_amount_sats() requires >= quorum sources to agree on the EXACT amount (a deterministic value; disagreement fails closed).

Satisfies the same duck-typed reader Protocol as MempoolSpaceFundingReader, so it is a drop-in for the reorg gate / funding read-back on above-dust swaps. A failing source is simply dropped from the quorum (never fails the whole read).

DEFAULT_MAINNET_ENDPOINTS = ('https://mempool.space/api', 'https://blockstream.info/api', 'https://mempool.emzy.de/api')

Default independent mainnet Esplora endpoints (distinct operators).

__init__(readers, *, quorum=2, dust_cap_sats=10000)[source]
Parameters:
Return type:

None

async close()[source]
Return type:

None

async confirmations(txid, *, value_sats=None)[source]

Quorum’d confirmation depth, returning the conservative MINIMUM.

value_sats selects the dust gate: None (the default, used by the reorg gate) or any value above dust_cap_sats REQUIRES the quorum and fails closed otherwise; a value at/below the cap accepts a single source.

Parameters:
  • txid (str)

  • value_sats (int | None)

Return type:

int

classmethod default_mainnet(*, quorum=2, dust_cap_sats=10000)[source]

Wire the three default independent mainnet Esplora endpoints (2-of-3).

Parameters:
  • quorum (int)

  • dust_cap_sats (int)

Return type:

MultiSourceBtcFundingReader

classmethod from_endpoints(urls, *, quorum=2, dust_cap_sats=10000, allow_insufficient_diversity=False)[source]

Build the reader from Esplora base URLs, requiring at least quorum DISTINCT hosts.

A quorum of same-host endpoints is false corroboration (one hostile/buggy/MITM’d host satisfies the whole “quorum”), so e.g. two mempool.space URLs can never form a genuine 2-of-2. By default this fails closed (raises ValidationError) when the endpoints resolve to fewer than quorum distinct hosts, rather than silently clamping the effective quorum down to 1 — a log-only clamp could arm single-source above-dust custody (the F-17 SPOF) on a misconfig.

Pass allow_insufficient_diversity=True to explicitly accept the degraded low-/single-source posture (mirrors the executor’s --accept-single-source dust opt-in); the clamp is then logged loudly so the operator sees the real corroboration level.

Parameters:
Return type:

MultiSourceBtcFundingReader

async list_address_utxos(address)[source]

UTXO discovery (not a value gate): return the first source that responds.

Parameters:

address (str)

Return type:

list[dict]

async read_confirmed_unspent_output(txid, vout)[source]

Quorum’d (scriptPubKey, value_sats) of a confirmed, unspent output.

The maker-side counter-funding gate decides whether to lock the maker’s own asset on this answer, so it gets the same F-17 treatment as the amount read: the EXACT (spk, value) pair must be corroborated by >= quorum sources above the dust cap, and a source that reports the output as spent/unconfirmed simply fails and drops out. Fail-closed when no source answers or the quorum is short — a single MITM’d endpoint must not be able to certify a funding output that is not there.

Parameters:
Return type:

tuple[bytes, int]

async read_output_amount_sats(txid, vout, *, min_confirmations)[source]

Quorum’d output-amount read-back. Above the dust cap the exact amount must be corroborated by >= quorum sources; the conf depth is quorum’d separately.

Parameters:
  • txid (str)

  • vout (int)

  • min_confirmations (int)

Return type:

int

async txid_of(raw_tx)[source]
Parameters:

raw_tx (bytes)

Return type:

str

class pyrxd.network.NetworkProfile[source]

Bases: object

An ordered endpoint list bound to one network, plus its chain fingerprint.

This is the object the client layer consumes: it answers both “where do I connect?” and “how do I know that server is on the chain I asked for?”.

network

mainnet / testnet / regtest (or any caller-defined name).

Type:

str

endpoints

Preference-ordered, de-duplicated, non-empty.

Type:

tuple[pyrxd.network.registry.Endpoint, …]

genesis_hash

Expected genesis block hash in display order, or None when pyrxd has no constant for this network (then no chain check is possible).

Type:

str | None

__init__(network, endpoints=<factory>, genesis_hash=None)
Parameters:
Return type:

None

classmethod build(network, urls, *, allow_insecure=False, spki_pins=(), genesis_hash=None)[source]

Build a profile from plain URLs, defaulting the genesis hash from the registry.

Pass genesis_hash explicitly only to override the shipped constant (a custom chain). Leaving it None looks the network up in GENESIS_BLOCK_HASHES, so build("mainnet", [...]) is chain-bound with no extra ceremony.

Parameters:
Return type:

NetworkProfile

genesis_hash: str | None = None
property urls: tuple[str, ...]
network: str
endpoints: tuple[Endpoint, ...]
pyrxd.network.block_hash_hex(header)[source]

Return the Radiant block hash of an 80-byte header, in display order.

Double SHA-512/256, reversed — see the module docstring for the Radiant-Core reference. Not SHA-256d: feeding a Radiant header through Bitcoin’s hash gives a value that matches nothing on any Radiant chain.

Raises:

ValidationError – if header is not exactly 80 bytes.

Parameters:

header (bytes)

Return type:

str

pyrxd.network.choose_funding_reader(value_sats, *, single, multi, dust_cap_sats=10000)[source]

Route a funding-reader choice by swap value (audit 2026-05-29 F-17).

Returns the SINGLE-source reader for a value at/below dust_cap_sats (the documented dust posture — a deliberate single-source SPOF the operator accepts for trivial value) and the MULTI-source quorum reader ABOVE it (fail-closed corroboration; see MultiSourceBtcFundingReader). Inject the result as a BtcLeg’s funding_reader.

single and multi may each be a reader INSTANCE or a zero-argument FACTORY — a factory is invoked only for the chosen reader, so the unused one (e.g. the quorum reader’s three HTTP sessions on a dust swap) is never built.

Use network-appropriate readers: the quorum reader’s default_mainnet() endpoints are mainnet-only, so a signet/testnet above-dust path must supply its own endpoint set.

Parameters:
  • value_sats (int)

  • dust_cap_sats (int)

pyrxd.network.default_endpoints(network)[source]

Shipped endpoints for network (possibly empty). Never falls across networks.

Parameters:

network (str)

Return type:

tuple[str, …]

pyrxd.network.genesis_hash_for(network)[source]

Expected genesis hash for network, or None for an unknown network.

None means “pyrxd cannot verify this binding”, not “the binding is fine”; callers should treat it as a reason to be more explicit, not less.

Parameters:

network (str)

Return type:

str | None

async pyrxd.network.wait_for_confirmation(client, txid, *, min_confirmations=1, interval_s=10.0, timeout_s=1800.0, sleep=<function sleep>, clock=<built-in function monotonic>, max_iterations=None)[source]

Poll client until txid has at least min_confirmations.

Returns the observed confirmation depth. Raises ConfirmationTimeoutError — a InsufficientConfirmationsError, therefore a NetworkError — if the deadline or max_iterations is reached first.

A NetworkError from a single poll is swallowed and the loop continues: right after a broadcast the tx is routinely not yet visible to the server, and that read fails. The last such error is remembered and named in the timeout message, so a persistently broken transport is still diagnosable rather than being reported as a bare “did not confirm”. (The pre-extraction helper claimed in its docstring to “re-raise on persistent network failure” — it never did; this documents and preserves the real, correct behaviour.)

Parameters:
  • client (Any) – anything exposing async get_transaction_verbose(Txid) -> dict (e.g. ElectrumXClient). Duck-typed on purpose so tests and alternative readers need no adapter class.

  • txid (str | Txid) – transaction to watch.

  • min_confirmations (int) – depth required before returning. Must be >= 1.

  • interval_s (float) – seconds between polls, passed to sleep.

  • timeout_s (float) – give up once clock() has advanced this far past the start.

  • sleep (Callable[[float], Awaitable[None]]) – awaitable sleep — inject to run without real time.

  • clock (Callable[[], float]) – monotonic-ish time source — inject to reach the timeout branch. It is read once at entry, then twice per iteration (the deadline test and the sleep clamp); a clock that never advances never times out, which is why max_iterations exists.

  • max_iterations (int | None) – hard bound on poll count. None = unbounded (the production default). Exhausting it raises the same timeout error with reason="max_iterations" — fail-closed, never a silent “confirmed”.

Return type:

int