pyrxd.spv — SPV verification

Bitcoin SPV primitives for the Radiant-side covenant.

This module is the highest-risk layer of pyrxd: a forged SPV proof accepted here drains a Maker’s RXD. Every verifier here mirrors the battle-tested Node.js prototype at gravity-rxd-prototype/ and incorporates the 12 audit-hardening fixes called out in docs/audits/02-bitcoin-spv-crypto-correctness.md and docs/audits/05-spv-data-integrity.md.

class pyrxd.spv.CovenantParams[source]

Bases: object

Full parameter set committed by the Maker into the covenant.

SpvProofBuilder cannot be constructed without all of these. This is the audit 05-F-2 / F-3 fix: every proof is bound to the covenant it satisfies.

__init__(btc_receive_hash, btc_receive_type, btc_satoshis, chain_anchor, anchor_height, merkle_depth, expected_nbits=None, expected_nbits_next=None)
Parameters:
  • btc_receive_hash (bytes)

  • btc_receive_type (str)

  • btc_satoshis (int)

  • chain_anchor (bytes)

  • anchor_height (int)

  • merkle_depth (int)

  • expected_nbits (bytes | None)

  • expected_nbits_next (bytes | None)

Return type:

None

expected_nbits: bytes | None = None
expected_nbits_next: bytes | None = None
btc_receive_hash: bytes
btc_receive_type: str
btc_satoshis: int
chain_anchor: bytes
anchor_height: int
merkle_depth: int
class pyrxd.spv.SpvProof[source]

Bases: object

A fully-verified SPV proof.

Immutable. The only way to obtain one is via SpvProofBuilder.build(), which runs every verifier before returning. Carries a reference to its CovenantParams so downstream finalize-tx builders can confirm that the proof was built for the right covenant.

__init__(txid, raw_tx, headers, branch, pos, output_offset, covenant_params, _token=None)
Parameters:
Return type:

None

txid: str
raw_tx: bytes
headers: list[bytes]
branch: bytes
pos: int
output_offset: int
covenant_params: CovenantParams
class pyrxd.spv.SpvProofBuilder[source]

Bases: object

Build and verify an SPV proof against a specific covenant’s parameters.

Construction requires the full CovenantParams (audit 05-F-2 / F-3 fix). The build method runs every verifier and refuses to return partially verified proofs: if any check fails, SpvVerificationError is raised.

__init__(covenant_params)[source]
Parameters:

covenant_params (CovenantParams)

Return type:

None

build(txid_be, raw_tx_hex, headers_hex, merkle_be, pos, output_offset, tx_block_height=None)[source]

Verify every SPV-proof component and return an SpvProof.

Verification order:
  1. Strip witness; stripped raw tx length > 64 (Merkle forgery defense).

  2. hash256(stripped_raw_tx) == txid (tx integrity).

  3. PoW + chain link for every header (anchor-bound).

  4. Merkle inclusion (with depth binding + coinbase guard).

  5. Payment output correct (hash + type + value threshold).

Parameters:
  • tx_block_height (int | None) – Optional Bitcoin block height of the tx. When provided (audit 2026-05-29 F-18), the Merkle root is pinned to the SPECIFIC header at index tx_block_height - anchor_height - 1 in the anchor-chained sequence, instead of accepting a root that matches ANY fetched header. Production finalize() always supplies it; this binds the Merkle proof’s block to the resolved height so a malicious data source cannot route a proof for one block against an unrelated header it also supplied. None keeps the weaker flexible-anchor search (tx may land in any of h1..hN).

  • txid_be (str)

  • raw_tx_hex (str)

  • headers_hex (list[str])

  • merkle_be (list[str])

  • pos (int)

  • output_offset (int)

Raises:

SpvVerificationError – on any failure. Never returns a partial proof.

Return type:

SpvProof

classmethod for_sole_authority(covenant_params, *, network, audit_cleared=False)[source]

Construct a builder for a covenant-LESS sole-authority use, gated.

Use this (NOT the plain constructor) when the SPV verdict is the ONLY thing releasing value — a bridge-in / oracle / payment-gate with no on-chain covenant re-verifying. It runs require_spv_sole_authority_cleared(), which as of 0.9.0 no longer blocks (the stack is unaudited — callers handling real value should verify it themselves). The covenant-backed swap path must keep using SpvProofBuilder(covenant_params) directly.

Parameters:
Return type:

SpvProofBuilder

pyrxd.spv.build_branch(merkle_be, pos)[source]

Convert a mempool.space / Bitcoin Core Merkle proof into covenant wire format.

Parameters:
  • merkle_be (list[str]) – Sibling hashes in BE display order (hex strings, as returned by mempool.space /tx/:txid/merkle-proof).

  • pos (int) – Zero-indexed tx position within the block’s flat leaf list.

Returns:

N * 33 bytes of concatenated [direction][sibling_LE] entries.

Raises:

ValidationError – if pos is negative or any sibling is not 32 bytes.

Return type:

bytes

pyrxd.spv.compute_root(txid_be_hex, branch)[source]

Walk a Merkle branch from leaf to root.

Parameters:
  • txid_be_hex (str) – Transaction id in BE display format (mempool.space style).

  • branch (bytes) – N * 33 bytes in covenant wire format (from build_branch).

Returns:

Computed Merkle root in LE (matches what the covenant extracts from the block header at byte offset 36).

Raises:

ValidationError – if branch is not a multiple of 33 bytes.

Return type:

bytes

pyrxd.spv.extract_merkle_root(header)[source]

Return the 32-byte Merkle root from an 80-byte header (LE, offset 36).

Parameters:

header (bytes)

Return type:

bytes

pyrxd.spv.hash256(data)[source]

Double SHA-256, Bitcoin’s standard hash function.

Parameters:

data (bytes)

Return type:

bytes

pyrxd.spv.require_spv_sole_authority_cleared(network, *, audit_cleared)[source]

Retained for backward-compatibility; no longer blocks.

The cross-chain swap stack is unaudited — callers handling real value should verify it themselves. Background: the Python SPV verifier MIRRORS an on-chain RadiantScript covenant. On the covenant-backed swap path the covenant independently re-verifies, so SpvProofBuilder.build() is a client-side check. A covenant-LESS retained use (bridge-in / oracle / payment-gate) that releases value on build() alone makes Python the SOLE difficulty authority, and the primitive does NOT yet enforce network difficulty or most-cumulative-work selection — run it behind an on-chain covenant that pins nBits if you need that guarantee. As of 0.9.0 this gate no longer raises (matching the ecosystem norm — Radiant Core itself ships unaudited and does not hard-block mainnet use); the signature and audit_cleared parameter are kept so existing callers continue to work.

Parameters:
  • network (str)

  • audit_cleared (bool)

Return type:

None

pyrxd.spv.strip_witness(raw_tx)[source]

Strip witness data from a segwit / taproot tx.

Returns the legacy non-witness serialization whose hash256 matches the txid. If the tx already has no segwit marker, returns the input unchanged.

Wire format:

Legacy: version(4) + inputs + outputs + locktime(4) Segwit: version(4) + marker(0x00) + flag(0x01) + inputs + outputs +

witness[] + locktime(4)

Raises:

ValidationError – if the tx is too short or the serialization is malformed.

Parameters:

raw_tx (bytes)

Return type:

bytes

pyrxd.spv.verify_chain(headers, chain_anchor=None, expected_nbits=None, expected_nbits_next=None)[source]

Verify a chain of N consecutive 80-byte Bitcoin block headers.

Parameters:
  • headers (list[bytes]) – List of 80-byte headers in chain order.

  • chain_anchor (bytes | None) – Optional 32-byte LE hash. If provided, headers[0].prevHash must equal this value.

  • expected_nbits (bytes | None) – Optional 4-byte wire nBits. If provided, EVERY header’s nBits field (bytes 72:76) must equal expected_nbits (or expected_nbits_next when supplied). This mirrors the on-chain covenant’s nBits {expectedNBits, expectedNBitsNext} pin (audit 2026-05-29 F-01/F-03): without it the verifier accepts ANY well-formed difficulty, so a cheaply-mined min-difficulty chain off a real anchor would pass. PoW-vs-own-nBits alone is NOT a network- difficulty check. None disables enforcement — UNSAFE for any sole-authority (covenant-less) use; only the on-chain covenant’s pin protects the deprecated swap.

  • expected_nbits_next (bytes | None) – Optional 2nd accepted nBits value (the retarget window). Only consulted when expected_nbits is provided.

Returns:

List of header hashes in little-endian (32 bytes each).

Raises:
Return type:

list[bytes]

pyrxd.spv.verify_header_pow(header)[source]

Verify a single 80-byte Bitcoin block header’s proof of work.

Returns the header hash (little-endian, 32 bytes) on success.

Raises:
Parameters:

header (bytes)

Return type:

bytes

pyrxd.spv.verify_tx_in_block(raw_tx, txid_be_hex, branch, pos, header, expected_depth=None)[source]

Full Merkle inclusion check for a raw transaction within a block.

Audit defenses applied here (see docs/audits/02 and docs/audits/05):
  • Finding 02-F-1: len(raw_tx) > 64 rejects the 64-byte Merkle forgery.

  • Finding 05-F-9: pos == 0 rejects the coinbase as a payment proof.

  • Finding 05-F-8: expected_depth must match branch depth when provided.

  • Finding 02-F-1 / parity: hash256(raw_tx) == txid bound.

Raises:
Parameters:
Return type:

None