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:
BtcDataSourceBtcDataSource backed by a Bitcoin Core JSON-RPC endpoint.
Credentials are stored as
SecretBytesand never logged.- Parameters:
url – RPC endpoint URL, e.g.
http://localhost:8332/.user – RPC username.
password – RPC password (stored securely as SecretBytes).
- async get_block_hash(height)[source]¶
Return the 32-byte block hash at height.
- Parameters:
height (BlockHeight)
- Return type:
- async get_block_header_hex(height)[source]¶
Return the raw 80-byte block header at height.
- Parameters:
height (BlockHeight)
- Return type:
- async get_header_chain(start_height, count)[source]¶
Return count consecutive 80-byte headers starting at start_height.
- Parameters:
start_height (BlockHeight)
count (int)
- Return type:
- async get_merkle_proof(txid, height)[source]¶
Return
(branch_hashes_hex, leaf_position)for txid at height.
- async get_outpoint_spender(txid, vout, *, max_scan_blocks=2016)[source]¶
The transaction spending
txid:voutas this node sees it, orNonewhile it is unspent.gettxout(mempool included) answers “unspent”. Otherwise the spender is looked for in the mempool (gettxspendingprevout, Bitcoin Core 24 and later; an older node skips this) and then in every block from the funding transaction’s block to the tip. Each candidate’s bytes are re-hashed against its txid and must spend the outpoint.Fails closed:
NetworkErrorwhen the node does not hold the outpoint unspent and no spender is found. That covers a funding transaction the node does not know, one that is unconfirmed with no spender in the mempool, and a span of more thanmax_scan_blocksblocks. On a node older than Core 24 a spender that is only in the mempool cannot be seen, so it raises too, saying so. Reading the funding transaction’s block needs-txindex(or a wallet that holds the transaction), as the rest of this class’s confirmed-transaction reads do. A spender is returned at whatever depth it has, mempool included; the caller decides what depth it needs before acting on it.It trusts this one node. A node that lags the chain (still syncing, or partitioned) answers from stale state, so “unspent” means unspent at that node’s tip. The txid check covers the witness-stripped bytes only: a witness the node serves is not bound by it.
- async get_raw_tx(txid, min_confirmations=6)[source]¶
Return raw transaction bytes, enforcing min_confirmations.
- class pyrxd.network.BlockstreamSource[source]¶
Bases:
BtcDataSourceBtcDataSource backed by the blockstream.info HTTP API.
- __init__(base_url='https://blockstream.info/api')[source]¶
- Parameters:
base_url (str)
- Return type:
None
- async get_block_hash(height)[source]¶
Return the 32-byte block hash at height.
- Parameters:
height (BlockHeight)
- Return type:
- async get_block_header_hex(height)[source]¶
Return the raw 80-byte block header at height.
- Parameters:
height (BlockHeight)
- Return type:
- async get_header_chain(start_height, count)[source]¶
Return count consecutive 80-byte headers starting at start_height.
- Parameters:
start_height (BlockHeight)
count (int)
- Return type:
- async get_merkle_proof(txid, height)[source]¶
Return
(branch_hashes_hex, leaf_position)for txid at height.
- async get_raw_tx(txid, min_confirmations=6)[source]¶
Return raw transaction bytes, enforcing min_confirmations.
- class pyrxd.network.BtcDataSource[source]¶
Bases:
ABCAbstract interface for blockchain data providers.
source_keynames the source — the operator group — a source reads from (source_key()of its URL), which is howMultiSourceBtcDataSourcecounts it.Nonemeans “cannot say”, and a quorum refuses such a source rather than guess.- 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:
- abstractmethod async get_block_header_hex(height)[source]¶
Return the raw 80-byte block header at height.
- Parameters:
height (BlockHeight)
- Return type:
- abstractmethod async get_header_chain(start_height, count)[source]¶
Return count consecutive 80-byte headers starting at start_height.
- Parameters:
start_height (BlockHeight)
count (int)
- Return type:
- abstractmethod async get_merkle_proof(txid, height)[source]¶
Return
(branch_hashes_hex, leaf_position)for txid at height.
- abstractmethod async get_raw_tx(txid, min_confirmations=6)[source]¶
Return raw transaction bytes, enforcing min_confirmations.
- abstractmethod async get_tip_height()[source]¶
Return the current chain tip block height.
- Return type:
- abstractmethod async get_tx_block_height(txid)[source]¶
Return the block height at which txid was confirmed.
Raises
NetworkErrorif the transaction is unconfirmed or not found.- Parameters:
txid (Txid)
- Return type:
- class pyrxd.network.ChainTracker[source]¶
Bases:
objectVerifies 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_rootin the header is stored in little-endian byte order, matching the convention used byMerklePath.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:
Trueif the header’s Merkle root matches merkle_root.- Return type:
- class pyrxd.network.ElectrumXClient[source]¶
Bases:
objectAsync 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 raiseNetworkErrorimmediately. Set toTrueonly 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 raisesTlsPinMismatchError. Seepyrxd.network.tls_pinfor the format and for why it is opt-in.
- 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 regtestpointed 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:
- 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.
This is the only place in pyrxd that issues
blockchain.transaction.broadcast, so the echo check lives here rather than at each caller: every broadcast through a pyrxd client crosses it, includingFailoverElectrumXClient, which delegates to this method per endpoint.- Parameters:
raw_tx (bytes) – Serialised transaction bytes.
- Returns:
The txid hashed from raw_tx — after the server’s reply has been checked against it. The server’s reply is never returned as such.
- Return type:
- Raises:
BroadcastEchoMismatch – The server replied with a well-formed txid for some other transaction. The broadcast may or may not have relayed; the exception carries
local_txid.PolicyRejection – The node refused the transaction (a reject code, a reject reason, or ElectrumX’s code 1, which on a broadcast wraps a failed
sendrawtransaction). This is the only client method that raises it: on a read the same error is aNetworkError.NetworkError – Transport failure, or a reply that is not a txid at all.
- 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
resultfield from the JSON-RPC response. Server errors raiseNetworkError. The caller is responsible for validating the result shape.
- 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_hashissha256(locking_script)with bytes reversed (ElectrumX little-endian convention). AcceptsHex32, rawbytes(length 32), or a hexstr(length 64).
- async get_block_header(height)[source]¶
Return the raw 80-byte block header at height.
- Parameters:
height (BlockHeight)
- Return type:
- async get_block_headers(start, count)[source]¶
Return up to count consecutive raw 80-byte headers from height start.
blockchain.block.headers, which serves at mostMAX_BLOCK_HEADERS_PER_CALL(2016) per call. The server answers{"count": n, "hex": ..., "max": ...};nmay be SMALLER than asked when the range runs past its tip, and that is honest, so fewer headers come back. What is refused, withNetworkError: a reply that is not an object, an echoedcountthat is not a non-negative int or is larger than asked, ahexthat is not a string of hex digits exactly160 * nlong. Each returned header is exactly 80 bytes. Nothing here checks what the headers say (linkage, proof-of-work): that is the consumer’s job, andpyrxd.glyph.mark_blockis the one that does it.- Raises:
ValidationError – for a count outside
1..2016or a bad start — a caller’s error, refused before anything is sent.- Parameters:
start (BlockHeight)
count (int)
- Return type:
- 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 haveheightof 0 or negative.
- 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:
- async get_transaction_id_from_pos(height, pos)[source]¶
The txid at position pos of block height, with its merkle branch, shape-validated.
blockchain.transaction.id_from_pos(height, pos, true). Returns{"tx_hash": <64 lowercase hex>, "merkle": [<64 lowercase hex>, ...]}— the shapepyrxd.glyph.mark_block.verify_mark_block()takes ascoinbase_merklewhen pos is 0. RaisesNetworkErrorfor a reply that is not an object, atx_hashthat is not 32 bytes of hex, or amerklethat is not a list of at mostMAX_MERKLE_DEPTH32-byte hex hashes. Not checked against any header here.- Parameters:
height (BlockHeight)
pos (int)
- Return type:
- async get_transaction_merkle(txid, height)[source]¶
Fetch the Merkle proof for txid at block height, as a BUMP
MerklePath.Built from
get_transaction_merkle_branch(): ONE BUMP level per tree depth — level 0 holds the txid leaf atposand its sibling atpos ^ 1; leveliholds the one sibling at offset(pos >> i) ^ 1. Through 0.25.1 every sibling was put in level 0, which raised on real mainnet proofs (“Missing hash for index 3 at height 0”, “Duplicate offset: 1, at height: 0” — measured on two HashMark txids against both shipped servers). A single-transaction block (empty branch,pos0) gives one level holding the txid alone, whose root is the txid itself (real block 1 of mainnet).The returned path is not checked against any header.
- Parameters:
txid (Txid)
height (BlockHeight)
- Return type:
MerklePath
- async get_transaction_merkle_branch(txid, height)[source]¶
Fetch txid’s merkle branch in block height, as ElectrumX sends it, shape-validated.
Returns a
TxMerkleBranch: the sibling hashes one per tree level, and the transaction’s position. It is NOT checked against any header here — that is the consumer’s job, andpyrxd.glyph.mark_blockis the one that does it. RaisesNetworkErrorfor a malformed reply or a proof for a different block than requested.- Parameters:
txid (Txid)
height (BlockHeight)
- Return type:
TxMerkleBranch
- async get_transaction_verbose(txid)[source]¶
Fetch the verbose JSON-decoded form of a transaction.
Calls
blockchain.transaction.getwithverbose=Trueand returns the dict the server provides — includingconfirmations,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 recomputeshash256(raw); the verbose form has no bytes to hash, so it binds thetxidthe node echoes (getrawtransaction <txid> truealways returns it — Radiant-Coresrc/rpc/rawtransaction.cpp, and ElectrumX’sblockchain.transaction.getpasses 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.
- async get_utxos(script_hash)[source]¶
Return the list of UTXOs for script_hash.
Accepts
Hex32, rawbytes(length 32), or a hexstr(length 64). Each UTXO is returned as a typedUtxoRecord.
- per_source_clients()[source]¶
One NEW client per operator group of this client’s URLs (
source_keysorder), each over that group’s URLs with this client’s TLS settings and timeout.This client races all its URLs, so a reply from it comes from ONE group and cannot say which; a caller that needs an answer from EACH operator asks these instead. The caller owns them and closes them. Nothing is connected here.
- Return type:
tuple[ElectrumXClient, …]
- class pyrxd.network.Endpoint[source]¶
Bases:
objectOne ElectrumX server, with the trust decisions that apply to it.
- allow_insecure¶
Permit a plaintext
ws://URL. Needed for a local regtest indexer; never appropriate for a public endpoint.- Type:
- spki_pins¶
Optional TLS SubjectPublicKeyInfo pins (
sha256/<base64>). Empty (the default) means pinning is OFF for this endpoint. Seepyrxd.network.tls_pinfor why that is the default.
- operator¶
Optional declared operator id (the config file’s
operator = "…"). It decides which SOURCE this endpoint is (source), overriding the grouping by registered domain.None(the default) means “not declared”.- Type:
str | None
- __init__(url, allow_insecure=False, spki_pins=(), operator=None)¶
- property key: str¶
ONE server, however its URL is spelled.
Case, a trailing slash, the scheme’s DEFAULT PORT written out (
wss://handwss://h:443) and a fully-qualified TRAILING DOT on the host (handh.) all name the same socket. The key used to fold only case and the slash, and the difference was not cosmetic: HashMark §7.6 form 2 treats two endpoints as two DISTINCT sources, sowss://evil.example/pluswss://evil.example:443/— one lying server — reached ESTABLISHED as though two servers had agreed (0.25.0 panel). Profiles de-duplicate by this key, so the two spellings now collapse into one endpoint and every source rule sees one source.The host is canonical (
canonical_host()), so an IP literal in another spelling is the same endpoint too. This key keeps the port, path and query: it is the CONNECT identity. Whether two endpoints are distinct SOURCES issource, which is coarser.What a URL CANNOT show is that two different names — a hostname and its IP address, or two DNS names — reach one machine. That is not detectable here and is not claimed.
- Type:
Normalised identity used for de-duplication
- property source: str¶
source_key().Coarser than
keyon purpose. Two endpoints of one operator — another path (wss://h/x), another port, the same IP address spelled another way, another host under one registered domain, or a host of an operator pyrxd knows (the two radiant4people servers) — are ONE source: a single lying operator reached through two such URLs must not corroborate itself (0.25.0 panel, round 3). This endpoint’s own declaredoperatordecides it instead; no other endpoint’s declaration, and nothing process-wide, does. Nothing is refused by this — a profile may still list both for failover; they simply do not count as two when HashMark §7.6 form 2 needs two.It is the same key every other source count in pyrxd uses — distinct operators, as declared, or by registered domain — with the same limit (the operator limit in
pyrxd.network.source_identity).- Type:
Which SOURCE this endpoint is, for counting sources
- class pyrxd.network.FailoverElectrumXClient[source]¶
Bases:
objectAn
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
ElectrumXClientkeep 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 itNoneand 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 itTruefor 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
ElectrumXClientbehaviour: 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:
profile (NetworkProfile)
timeout (float)
client_factory (Callable[[Endpoint], ElectrumXClient] | None)
verify_chain (bool)
- Return type:
None
- async assert_chain(expected_genesis_hash)[source]¶
Verify the active endpoint’s chain. Failover applies (a dead endpoint is skipped).
- 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:
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:667rejects any mempool conflict outright astxn-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 isbroadcast(raw_tx: bytes)precisely so this class cannot rebuild anything.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.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-27is 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.
- 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.methodis harmless. Defaulting to “retry” would be exactly the blind retry of a possibly-non-idempotent call that this module is careful to avoid. Passidempotent=Truefor a read (glyph.get_token,wave.resolve, …).An idempotent call also moves past an endpoint that answers this method with “method not found” (a server without the indexer extension), without dropping or demoting the endpoint for it — see
_run(). A non-idempotent call treats that answer as a fault, as every other call does.
- async close()[source]¶
Close every underlying client. Safe to call more than once.
- Return type:
None
- async get_block_header(height)[source]¶
- Parameters:
height (BlockHeight)
- Return type:
- async get_block_headers(start, count)[source]¶
- Parameters:
start (BlockHeight)
count (int)
- Return type:
- async get_transaction_id_from_pos(height, pos)[source]¶
- Parameters:
height (BlockHeight)
pos (int)
- Return type:
- async get_transaction_merkle(txid, height)[source]¶
- Parameters:
txid (Txid)
height (BlockHeight)
- Return type:
MerklePath
- async get_transaction_merkle_branch(txid, height)[source]¶
- Parameters:
txid (Txid)
height (BlockHeight)
- Return type:
TxMerkleBranch
- property profile: NetworkProfile¶
- class pyrxd.network.MempoolSpaceSource[source]¶
Bases:
BtcDataSourceBtcDataSource 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 get_block_hash(height)[source]¶
Return the 32-byte block hash at height.
- Parameters:
height (BlockHeight)
- Return type:
- async get_block_header_hex(height)[source]¶
Return the raw 80-byte block header at height.
- Parameters:
height (BlockHeight)
- Return type:
- async get_header_chain(start_height, count)[source]¶
Return count consecutive 80-byte headers starting at start_height.
- Parameters:
start_height (BlockHeight)
count (int)
- Return type:
- async get_merkle_proof(txid, height)[source]¶
Return
(branch_hashes_hex, leaf_position)for txid at height.
- async get_raw_tx(txid, min_confirmations=6)[source]¶
Return raw transaction bytes, enforcing min_confirmations.
- class pyrxd.network.MultiSourceBtcDataSource[source]¶
Bases:
BtcDataSourceA 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
BtcDataSourceinstances of DISTINCT OPERATORS (known operators, else registered domain; this quorum takes no declarations). Each must carry asource_key(every shipped source derives one from its URL); two sources with one key are refused, because one operator agreeing with itself is not a quorum. That grouping is not proof of independence — seepyrxd.network.source_identity.quorum – Minimum number of agreeing sources required (default 2).
- __init__(sources, quorum=2)[source]¶
- Parameters:
sources (list[BtcDataSource])
quorum (int)
- Return type:
None
- async get_block_hash(height)[source]¶
Return the 32-byte block hash at height.
- Parameters:
height (BlockHeight)
- Return type:
- async get_block_header_hex(height)[source]¶
Return the raw 80-byte block header at height.
- Parameters:
height (BlockHeight)
- Return type:
- async get_header_chain(start_height, count)[source]¶
Return count consecutive 80-byte headers starting at start_height.
- Parameters:
start_height (BlockHeight)
count (int)
- Return type:
- async get_merkle_proof(txid, height)[source]¶
Return
(branch_hashes_hex, leaf_position)for txid at height.
- async get_raw_tx(txid, min_confirmations=6)[source]¶
Return raw transaction bytes, enforcing min_confirmations.
- async get_tip_height()[source]¶
Highest tip a strict MAJORITY of the responding sources corroborate (never fewer than
quorumof 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]atquorum=1, or an even 2/2 split atquorum=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
Hasserts “the chain has reached at leastH”, which implies every weaker claimtip >= hforh <= H. Corroboration therefore does not need identical replies — the count backing a candidateHis simply how many sources reported at leastH. Sorting the answers descending, the value at indexr - 1is the largestHthatrsources back. Setrto a strict majority of the sources that answered, floored atquorum:Inflation is refused. A minority reporting a high tip sits above index
r - 1and 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 thequorum-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 dragmin()to 0 forever and stall every confirmation wait behind it. A minority low answer sits below indexr - 1and 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.
quorumstays 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 withquorum=5the answer washeights[4], the minimum, so one source reporting 0 returned 0. That is the exactmin()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) - rreadings 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 astip - tx_heightcan read one block deeper than the most cautious source would say. That is the exchange: tolerate up tolen(sources) - rmalicious 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) == 2a 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.quorumbelow the majority no longer buys availability, because the majority governs. It still governs_require_quorum()’s minimum group size.
- Return type:
- class pyrxd.network.MultiSourceBtcFundingReader[source]¶
Bases:
objectQuorum
BtcFundingReaderover N Esplora-style providers of DISTINCT OPERATORS.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 depth a quorum of responding sources vouches for (depth_vouched_by_quorum()): one over-reporter cannot raise it, and one under-reporter cannot hold it down while a quorum of others answers. With exactlyquorumanswers it is their minimum.read_output_amount_sats()requires >=quorumsources 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')¶
three registered domains (mempool.space, blockstream.info, emzy.de), so three sources — not proven independent (the operator limit in
pyrxd.network.source_identity).- Type:
Default mainnet Esplora endpoints
- async confirmations(txid, *, value_sats=None)[source]¶
Quorum’d confirmation depth: the deepest that at least
quorumsources report (depth_vouched_by_quorum(); the minimum when fewer answer).value_satsselects the dust gate:None(the default, used by the reorg gate) or any value abovedust_cap_satsREQUIRES the quorum and fails closed otherwise; a value at/below the cap accepts a single source.
- classmethod default_mainnet(*, quorum=2, dust_cap_sats=10000)[source]¶
Wire the three default mainnet Esplora endpoints (three registered domains, 2-of-3).
- Parameters:
- Return type:
- 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
quorumDISTINCT sources.URLs are grouped by
source_key()(by registered domain, or an operator pyrxd ships knowledge of; no declaration reaches this count), and each group becomes ONE reader — several URLs of one group are that reader’s failover list (SameSourceFailover), never several votes.A quorum of one operator’s endpoints is false corroboration (one hostile/buggy/MITM’d operator satisfies the whole “quorum”), so e.g. two
mempool.spaceURLs can never form a genuine 2-of-2. By default this fails closed (raisesValidationError) when the endpoints resolve to fewer thanquorumdistinct sources, 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=Trueto explicitly accept the degraded low-/single-source posture (mirrors the refund executor’s--auto-refund-on-single-sourcedust opt-in); the clamp is then logged loudly so the operator sees the real corroboration level.
- async list_address_utxos(address)[source]¶
UTXO discovery (not a value gate): return the first source that responds.
- panel()[source]¶
(readers, quorum): the per-source readers and how many must agree, for a read that asks each source itself (the BTC disposition reader, #850 PR 9a). One reader per source, as the constructor enforces.
- 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 >=quorumsources 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.
- class pyrxd.network.NetworkProfile[source]¶
Bases:
objectAn 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?”.
- endpoints¶
Preference-ordered, de-duplicated, non-empty.
- Type:
- genesis_hash¶
Expected genesis block hash in display order, or
Nonewhen pyrxd has no constant for this network (then no chain check is possible).- Type:
str | None
- __init__(network, endpoints=<factory>, genesis_hash=None)¶
- classmethod build(network, urls, *, allow_insecure=False, spki_pins=(), genesis_hash=None, operators=None)[source]¶
Build a profile from plain URLs, defaulting the genesis hash from the registry.
Pass
genesis_hashexplicitly only to override the shipped constant (a custom chain). Leaving itNonelooks the network up inGENESIS_BLOCK_HASHES, sobuild("mainnet", [...])is chain-bound with no extra ceremony. operators maps a URL (exactly as given in urls) to its declared operator (Endpoint.operator). The declaration stays on that endpoint: it decidesEndpoint.sourceand nothing else in the process.
- 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:
- 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; seeMultiSourceBtcFundingReader). Inject the result as aBtcLeg’sfunding_reader.singleandmultimay 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.
- pyrxd.network.default_endpoints(network)[source]¶
Shipped endpoints for network (possibly empty). Never falls across networks.
- pyrxd.network.genesis_hash_for(network)[source]¶
Expected genesis hash for network, or
Nonefor an unknown network.Nonemeans “pyrxd cannot verify this binding”, not “the binding is fine”; callers should treat it as a reason to be more explicit, not less.
- 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
clientuntiltxidhas at leastmin_confirmations.Returns the observed confirmation depth. Raises
ConfirmationTimeoutError— aInsufficientConfirmationsError, therefore aNetworkError— if the deadline ormax_iterationsis reached first.A
NetworkErrorfrom 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.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_iterationsexists.max_iterations (int | None) – hard bound on poll count.
None= unbounded (the production default). Exhausting it raises the same timeout error withreason="max_iterations"— fail-closed, never a silent “confirmed”.
- Return type: