pyrxd.btc_wallet — BTC-side wallet

Bitcoin wallet tooling for the Gravity Taker.

Public API

BtcKeypair — keypair with all 4 address formats BtcUtxo — UTXO descriptor BtcPaymentTx — signed transaction result generate_keypair — generate a fresh keypair from CSPRNG keypair_from_wif — load keypair from WIF (testing/recovery) build_payment_tx — build+sign a 1-input segwit-v0 payment tx validate_btc_address — validate a mainnet Bitcoin address string validate_satoshis — validate a satoshi amount

class pyrxd.btc_wallet.BitcoinCoreBroadcaster[source]

Bases: object

BtcBroadcaster backed by a Bitcoin Core sendrawtransaction RPC.

Intended for the regtest milestone (a local node). Reuses the injected rpc(method, params) coroutine so it shares transport/auth with a BitcoinCoreRpcSource rather than opening a second session. Idempotent: an “already known” node response is mapped to the tx’s own txid as success.

__init__(rpc)[source]
Return type:

None

async broadcast(raw_tx)[source]
Parameters:

raw_tx (bytes)

Return type:

str

class pyrxd.btc_wallet.BitcoinTaprootLeg[source]

Bases: object

The concrete BTC HTLC leg (the production btc_leg).

Parameters:
  • network – BTC network prefix (“bcrt” regtest, “tb” testnet/signet, “bc” mainnet).

  • funding_utxo (taker_keypair /) – The taker’s wallet key + the single UTXO that funds the HTLC (one input is the covenant structural constraint of build_payment_tx). funding_utxo must hold btc_sats + fee_sats (plus dust slack for change).

  • broadcaster – A BtcBroadcaster (idempotent).

  • funding_reader – A BtcFundingReader — reads the funded amount from the chain.

  • claim_to_scriptpubkey (refund_to_scriptpubkey /) – Where the refund (taker) and claim (maker) spends pay out.

  • fee_sats – Flat fee for the funding/claim/refund txs (regtest milestone; a fee estimator is a later refinement).

  • min_confirmations – Confirmations required before the on-chain funded amount is trusted.

  • audit_cleared – Explicit opt-in for a value-bearing network (see require_audit_cleared()). Ignored for isolated test chains.

__init__(*, network, taker_keypair, funding_utxo, maker_claim_pubkey_xonly, broadcaster, funding_reader, refund_to_scriptpubkey, claim_to_scriptpubkey, policy=None, maker_claim_privkey=None, audit_cleared=False, fee_sats=<object object>, min_confirmations=<object object>, funding_input_type=<object object>, fund_confirm_poll_s=<object object>, fund_confirm_timeout_s=<object object>)[source]
Parameters:
Return type:

None

async claim(locator, preimage)[source]

Build + idempotently broadcast the maker’s claim tx (reveals p).

Only a MAKER-role leg (constructed with maker_claim_privkey) can do this — the claim spend uses the maker’s claim-leaf key. A taker-role leg without that key fail-closes. build_claim_tx re-verifies sha256(p) opens the leaf hashlock before signing.

Parameters:
Return type:

str

async confirmations_of_claim(claim_tx_bytes)[source]

Confirmation depth of the maker’s BTC claim tx (the reorg gate’s input).

The txid is derived LOCALLY, by re-serialising the exact claim_tx_bytes p was scraped from (taproot.btc_txid_from_raw) — so an attacker can’t reveal p in a shallow tx while pointing the gate at a deep unrelated tx. Only the DEPTH of that txid is then read from the reader. Fail-closed: any read/derivation error propagates (the coordinator then refuses to claim).

Until 2026-09-03 this said the txid was “resolved VIA THE NODE … never a local segwit parse”, contradicting the comment in its own body and every shipped BtcFundingReader.txid_of. Serialising the bytes in hand is what makes the property hold: no third party gets to name the tx whose depth the gate trusts.

Parameters:

claim_tx_bytes (bytes)

Return type:

int

derive_funding_scriptpubkey(terms)[source]

The funding SPK the taker independently re-derives from the terms.

Return type:

bytes

async fund(terms)[source]

Fund the HTLC P2TR address from the taker’s UTXO; return the locator.

Build → idempotent-broadcast → read the funded amount back from the chain (D4: the amount is the ON-CHAIN value, never a self-report). The funding tx pays output 0 to the HTLC address; change (if any) returns to the taker.

Return type:

BtcHtlcLocator

locked_amount(locator)[source]

The funded amount the coordinator binds to terms.value_amount — sats for BTC (the chain-neutral seam; an ETH leg returns wei).

Return type:

int

promised_funding_scriptpubkey(terms)[source]

The funding SPK the maker promised.

For the HTLC there is no separate maker-side derivation — the SPK is a pure function of the negotiated terms, so the promised SPK equals the re-derived one. (The pre-lock gate’s equality check still runs; a divergence here would signal a terms/derivation bug.)

Return type:

bytes

async refund(locator, timeout)[source]

Build + idempotently broadcast the taker’s CSV refund tx. Returns the txid.

The refund leaf spends via the taker’s refund key (held by this leg) after the relative timelock matures. Idempotent broadcast tolerates a retry.

P3 maturity self-check: the refund spends the CSV leaf (BIP68/112 relative-block timelock), final only once the FUNDING utxo is buried timeout deep (mature at funding confirmations >= timeout). Refuse a non-final refund HERE with an exact “needs N confirmations, has M” message a block-based poller retries on — rather than emitting a tx a node rejects (under a deadline-pinning mempool “rely on node rejection” is fragile). The twin of RadiantCovenantLeg.refund_asset’s covenant-CSV check. Only the block-CSV case is confs-checkable; a (non-default) time-based CSV is left to the node.

Parameters:
Return type:

str

scrape_secret(claim_tx_bytes, hashlock)[source]

Scrape p from the maker’s claim tx witness (pure; by sha256==H).

Parameters:
Return type:

bytes

async verify_counterparty_funded(funding_ref, terms, *, min_confirmations=None)[source]

MAKER-side fail-closed gate: bind the counterparty’s advertised funding OUTPOINT to the HTLC this leg re-derives from its OWN copy of terms, reading the output AUTHORITATIVELY from the node. Returns the locator the maker may act on; RAISES on any mismatch — the maker MUST NOT lock its asset if this raises. The BTC twin of pyrxd.gravity.eth_leg.EthLeg.verify_counterparty_funded().

WHY: the runbook is TAKER-funds-BTC-FIRST, MAKER-locks-asset-SECOND, and a P2TR scriptPubKey commits to the TAPTREE, not to the output VALUE. So every SPK-derivation check in the handshake still passes on an HTLC funded with the WRONG AMOUNT, and the coordinator’s amount bind lives inside taker_funds_btc — the taker’s own method, which a hostile taker simply never calls (it funds the freely-derivable HTLC address directly). Without this gate the maker locks its asset, claims the under-funded BTC (revealing p), the taker claims the full asset, and the maker is paid less than the agreed price.

What is checked, in order (all fail-closed):

  1. the reader can report a CONFIRMED, UNSPENT output at all (BtcConfirmedOutputReader); a reader without that capability, or a spent / unconfirmed / unknown outpoint, refuses;

  2. the real on-chain scriptPubKey equals the HTLC re-derived here from terms (maker’s claim key + taker’s refund key + H + t_btc). Comparing a supplied locator’s own scriptpubkey() would prove nothing: the counterparty controls every field of it and can present a correct tree while pointing the outpoint at a decoy output it owns;

  3. the on-chain value equals terms.value_amount EXACTLY — under- AND over-funding are both rejected, matching SwapCoordinator.taker_funds_btc’s bind (an over-funded HTLC is a one-sided TAKER loss, since the claim leaf does not cap value). For a BTC swap value_amount is btc_sats (NegotiatedTerms enforces the equality);

  4. the funding is buried min_confirmations deep. PoW finality IS a confirmation depth (see pyrxd.gravity.finality.CounterClaimFinality), and a shallow funding can be reorged out from under the maker AFTER it locks. None uses this leg’s configured min_confirmations; the coordinator passes the policy’s BTC reorg depth for a real-value swap.

The returned locator is rebuilt from THIS leg’s derivation + the on-chain outpoint/amount, so nothing counterparty-supplied survives into the maker’s later claim.

Parameters:

min_confirmations (int | None)

Return type:

BtcHtlcLocator

class pyrxd.btc_wallet.BtcBroadcaster[source]

Bases: Protocol

Submit a raw BTC tx to the network. Composed into the leg (not on the ABC).

broadcast MUST be idempotent: if the node already knows the tx, return its txid as success rather than raising — a crash-recovery retry re-broadcasts the same tx and must not be treated as a failure.

__init__(*args, **kwargs)
async broadcast(raw_tx)[source]

Broadcast raw_tx; return the broadcast txid (BE hex).

Parameters:

raw_tx (bytes)

Return type:

str

class pyrxd.btc_wallet.BtcFundingReader[source]

Bases: Protocol

Read BTC chain state the HTLC leg needs: funding amount, confirmation depth, and the canonical txid of a raw tx.

Duck-typed over a BtcDataSource-like object. read_output_amount_sats returns the value of (txid, vout) as committed on-chain (NOT a self-report), enforcing min_confirmations (raise/fail-closed if shallower). confirmations is the symmetric confirmation-depth reader (mirrors RadiantChainIO.confirmations) the reorg gate consumes. txid_of resolves a raw tx’s canonical txid LOCALLY, by re-serialising the non-witness form (taproot.btc_txid_from_raw) — every shipped implementation does, including the Bitcoin Core one, because on mainnet there is no node to decoderawtransaction.

Until 2026-09-03 this said “VIA THE NODE — never a local segwit parse”, which had the mechanism exactly backwards. The safety property is unchanged, and is if anything better served by the local derivation: the gated txid must be that of the EXACT bytes p was scraped from, never a counterparty-supplied id. Serialising those bytes yields that txid without asking anyone; a node round-trip would be one more party to trust. btc_txid_from_raw is fail-closed on any structural problem, and a mis-derived txid reads 0 confs at the gate — never a false depth.

__init__(*args, **kwargs)
async confirmations(txid)[source]

Return the confirmation depth of txid (0 if unconfirmed/unknown).

Parameters:

txid (str)

Return type:

int

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

Return the on-chain satoshi value of (txid, vout) at >= min_confirmations.

Parameters:
  • txid (str)

  • vout (int)

  • min_confirmations (int)

Return type:

int

async txid_of(raw_tx)[source]

Derive raw_tx’s canonical txid from the bytes themselves (no node round-trip).

Parameters:

raw_tx (bytes)

Return type:

str

class pyrxd.btc_wallet.BtcHtlc[source]

Bases: object

The HTLC funding artifact, before a UTXO funds it.

Carries the script tree, control blocks for each leaf, the NUMS internal key, and the derived funding address/scriptPubKey. with_funding produces a BtcHtlcLocator once the funding outpoint + amount are known.

__init__(script_tree, internal_key, control_block_claim, control_block_refund, network)
Parameters:
Return type:

None

property address: str
property output_key: bytes
property scriptpubkey: bytes
with_funding(outpoint, amount_sats)[source]
Parameters:
Return type:

BtcHtlcLocator

script_tree: ScriptTree
internal_key: bytes
control_block_claim: bytes
control_block_refund: bytes
network: str
class pyrxd.btc_wallet.BtcHtlcLocator[source]

Bases: object

The FULL durable retained state for a funded BTC HTLC.

This is NOT opaque — it is everything required to later claim or refund the output. Persisting a reduced form (e.g. only the privkey) strands the BTC, because the script-path spend needs the whole Tapscript tree + control block.

__init__(funding_outpoint, script_tree, control_block_claim, control_block_refund, internal_key, amount_sats, network='bc')
Parameters:
Return type:

None

property address: str
classmethod from_dict(d)[source]
Parameters:

d (dict)

Return type:

BtcHtlcLocator

network: str = 'bc'
property output_key: bytes
property scriptpubkey: bytes

OP_1 <32-byte output key>.

Type:

The P2TR scriptPubKey

to_dict()[source]

JSON/hex-serialisable form — NEVER contains the preimage p.

Return type:

dict

funding_outpoint: BtcOutpoint
script_tree: ScriptTree
control_block_claim: bytes
control_block_refund: bytes
internal_key: bytes
amount_sats: int
class pyrxd.btc_wallet.BtcKeypair[source]

Bases: object

A Bitcoin keypair with addresses in all 4 Gravity-supported formats.

Private key is stored as PrivateKeyMaterial (never logs/repr leaks).

network

bech32 HRP ("bc" mainnet, "tb" testnet/signet, "bcrt" regtest, or any custom HRP). Defaults to "bc".

Type:

str

__init__(_privkey, pubkey_bytes, p2pkh_address, p2wpkh_address, p2sh_p2wpkh_address, p2tr_address, pkh, p2sh_hash, p2tr_output_key, network='bc')
Parameters:
Return type:

None

network: str = 'bc'
unsafe_wif()[source]

Export WIF. Named ‘unsafe’ to be visible in code review.

Uses the WIF version byte for self.network (0x80 for mainnet, 0xEF for testnet/signet/regtest).

Return type:

str

pubkey_bytes: bytes
p2pkh_address: str
p2wpkh_address: str
p2sh_p2wpkh_address: str
p2tr_address: str
pkh: bytes
p2sh_hash: bytes
p2tr_output_key: bytes
class pyrxd.btc_wallet.BtcOutpoint[source]

Bases: object

A funding outpoint (txid big-endian hex as shown by explorers, + vout).

__init__(txid, vout)
Parameters:
Return type:

None

classmethod from_dict(d)[source]
Parameters:

d (dict)

Return type:

BtcOutpoint

prevout_bytes()[source]

Serialise as the 36-byte wire outpoint (txid LE || vout LE).

Return type:

bytes

to_dict()[source]
Return type:

dict

txid: str
vout: int
class pyrxd.btc_wallet.BtcPaymentTx[source]

Bases: object

Result of build_payment_tx.

__init__(tx_hex, txid, fee_sats, change_sats, input_type, output_type)
Parameters:
  • tx_hex (str)

  • txid (str)

  • fee_sats (int)

  • change_sats (int)

  • input_type (str)

  • output_type (str)

Return type:

None

tx_hex: str
txid: str
fee_sats: int
change_sats: int
input_type: str
output_type: str
class pyrxd.btc_wallet.BtcUtxo[source]

Bases: object

A Bitcoin UTXO to spend.

__init__(txid, vout, value)
Parameters:
Return type:

None

txid: str
vout: int
value: int
class pyrxd.btc_wallet.ScriptTree[source]

Bases: object

A 2-leaf Tapscript tree (claim leaf + refund leaf).

Holds the leaf scripts and their (cached) leaf hashes + merkle root, so the durable swap state never has to re-derive (and risk mis-deriving) the tree.

__init__(claim_script, refund_script, leaf_version=192)
Parameters:
Return type:

None

property claim_leaf_hash: bytes
leaf_version: int = 192
property merkle_root: bytes
property refund_leaf_hash: bytes
script_for(which)[source]
Parameters:

which (str)

Return type:

bytes

sibling_for(which)[source]

Return the merkle-path sibling hash for the named leaf (“claim”|”refund”).

Parameters:

which (str)

Return type:

bytes

claim_script: bytes
refund_script: bytes
class pyrxd.btc_wallet.TimeUnit[source]

Bases: Enum

The unit a Timelock is measured in.

The whole cross-chain safety invariant rides on comparing like units, and since #567 that comparison is in SECONDS, not raw block counts:

t_rxd * i_rxd >= t_btc * i_btc + margin * i_btc

The maker holds p, LOCKS the Radiant leg and CLAIMS the counter leg, so the Radiant leg carries the LONGER timeout (#482). Until 2026-09-02 this docstring stated the reverse — t_BTC - t_RXD >= margin — and called it “the whole safety invariant”; that is the layout in which the maker takes both legs, deterministically. Mixing blocks and seconds without conversion is a fail-closed error, not a silent coercion.

BLOCKS = 'blocks'
SECONDS = 'seconds'
class pyrxd.btc_wallet.Timelock[source]

Bases: object

A unit-tagged relative timelock (BIP68/112 CSV).

__init__(value, unit)
Parameters:
Return type:

None

consensus_maturity_s(*, block_interval_s)[source]

Wall-clock seconds this relative lock actually holds for, AS CONSENSUS ENCODES IT.

NOT normalize_to(SECONDS). That round-trips a SECONDS lock through the CALLER’S block grid (floor(value / block_interval_s) * block_interval_s), and BIP68 does not quantise time locks on that grid — it quantises them to 512 s. The two disagree in BOTH directions: measured over every encodable value at block_interval_s=600, the block-grid answer is up to 592 s LOW (at 20992 s) and up to 504 s HIGH (at 17400 s).

Which direction is unsafe depends on which side of a comparison the value lands on, so the fix is not a rounding rule but an exact quantity. Derived from to_nsequence() so it cannot drift from the bytes the refund leaf actually pushes:

  • BLOCKS — value blocks, priced at the caller’s measured block_interval_s. There is no rounding here at all; the only estimate is the interval.

  • SECONDS — (value // 512) * 512, the encoded 512-second time units, exactly.

block_interval_s is only consulted for a BLOCKS-tagged lock; it is still validated for both so a bad interval fails closed rather than silently applying to one unit and not the other.

Parameters:

block_interval_s (float)

Return type:

float

csv_script_operand()[source]

Return the integer that the CSV leaf pushes (matches nSequence encoding).

The value compared by OP_CSV is the nSequence value masked to its relative-locktime bits, so the script operand equals to_nsequence() for the same lock.

Return type:

int

normalize_to(unit, *, block_interval_s)[source]

Return an equivalent Timelock in unit.

block_interval_s is the assumed seconds-per-block used for conversion (caller supplies a measured value for mainnet; estimates are test-only). Conversion is floor-based; the margin check must account for the rounding.

Parameters:
Return type:

Timelock

to_nsequence()[source]

Encode this relative timelock as a BIP68 nSequence value.

Return type:

int

value: int
unit: TimeUnit
pyrxd.btc_wallet.build_claim_tx(*, locator, preimage, claim_privkey, to_scriptpubkey, fee_sats, aux_rand, fee_policy=None)[source]

Build the maker’s claim tx (spends the claim leaf, reveals p).

Witness: <sig> <preimage> <claim_script> <control_block>.

fee_policy supplies the min-relay rate the assembled transaction is checked against, defaulting to DEFAULT_BITCOIN_DEADLINE_FEE_POLICY (Bitcoin Core’s DEFAULT_MIN_RELAY_TX_FEE, 1 sat/vB). Pass a policy built from the target node’s own getnetworkinfo/relayfee when it advertises something else. Raises InsufficientFundsError below that floor — see _assert_spend_fee_clears_relay_floor().

Parameters:
Return type:

bytes

pyrxd.btc_wallet.build_htlc(*, hashlock, claim_pubkey_xonly, refund_pubkey_xonly, timeout, internal_key_xonly=b'P\x92\x9bt\xc1\xa0IT\xb7\x8bK`5\xe9z^\x07\x8aZ\x0f(\xec\x96\xd5G\xbf\xee\x9a\xce\x80:\xc0', network='bc')[source]

Construct the BTC Taproot HTLC (funding address + control blocks).

The default internal key is the provable NUMS point — every spend is script-path, so a colluding maker cannot key-path-spend without revealing p.

Parameters:
Return type:

BtcHtlc

pyrxd.btc_wallet.build_payment_tx(keypair, utxo, to_hash, to_type, amount_sats, fee_sats, input_type='p2wpkh', change_address=None, fee_policy=None)[source]

Build and sign a 1-input Bitcoin payment transaction for the Gravity Taker.

Exactly 1 input is required — this is a covenant structural constraint. input_type controls whether the input is native segwit (empty scriptSig) or wrapped segwit (23-byte scriptSig with P2WPKH redeem push).

fee_policy supplies the min-relay rate the assembled transaction is checked against, defaulting to DEFAULT_BITCOIN_DEADLINE_FEE_POLICY (Bitcoin Core’s DEFAULT_MIN_RELAY_TX_FEE, 1 sat/vB). Pass a policy built from the target node’s own getnetworkinfo/relayfee when it advertises something else. Raises InsufficientFundsError below that floor — see _assert_payment_fee_clears_relay_floor().

Parameters:
Return type:

BtcPaymentTx

pyrxd.btc_wallet.build_refund_tx(*, locator, refund_privkey, timeout, to_scriptpubkey, fee_sats, aux_rand, fee_policy=None)[source]

Build the taker’s pre-signed refund tx (spends the refund leaf via CSV).

v2 tx with nSequence encoding the relative timelock per BIP68; witness is <sig> <refund_script> <control_block> (the refund leaf has no preimage).

fee_policy supplies the min-relay rate the assembled transaction is checked against — see _assert_spend_fee_clears_relay_floor(). It matters more here than on the claim leg: this transaction is routinely built ahead of time and persisted for a watchtower to broadcast later, so nothing re-checks it in between.

Parameters:
Return type:

bytes

pyrxd.btc_wallet.generate_keypair(network='bc')[source]

Generate a fresh Bitcoin keypair using CSPRNG.

Uses secure_scalar_mod_n() for the private key — explicit range check, rejection sampling, never Math.random(). Matches JS btc_wallet.js::generateKeypair() audit-hardened version.

Parameters:

network (str) – bech32 HRP for address serialization. "bc" (default) for mainnet, "tb" for testnet/signet, "bcrt" for regtest, or any custom HRP.

Return type:

BtcKeypair

pyrxd.btc_wallet.keypair_from_wif(wif, network='bc')[source]

Load keypair from WIF string (for testing/recovery).

Parameters:
  • wif (str) – WIF-encoded private key.

  • network (str) – bech32 HRP for address serialization (see generate_keypair). Note: this controls OUTPUT address/WIF encoding only; the input WIF is decoded regardless of its version byte.

Return type:

BtcKeypair

pyrxd.btc_wallet.require_audit_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. This matches the ecosystem norm (Radiant Core itself ships unaudited and does not hard-block mainnet use): the in-code audit gate is no longer a blocking control as of 0.9.0. The signature and audit_cleared parameter are kept so existing callers continue to work.

Parameters:
  • network (str)

  • audit_cleared (bool)

Return type:

None

pyrxd.btc_wallet.scrape_secret(claim_tx_bytes, hashlock)[source]

Extract the preimage p from a claim tx by matching sha256(p)==H.

Matches over EVERY witness push of EVERY input — never by positional offset (the C-PARSER lesson). Returns the 32-byte preimage. Raises ValidationError if no witness push hashes to H (e.g. this is a refund tx, or the wrong tx).

The hashlock disambiguates which swap this tx belongs to; the caller should pair it with the funding outpoint when multiple swaps share an H.

Parameters:
Return type:

bytes

pyrxd.btc_wallet.validate_btc_address(address)[source]

Validate a mainnet Bitcoin address.

Rejects path traversal, query injection, and anything outside the two recognized mainnet address shapes (Base58Check P2PKH/P2SH and bech32/bech32m).

Raises:

ValidationError – if the address is not a recognized mainnet format.

Parameters:

address (str)

Return type:

None

pyrxd.btc_wallet.validate_satoshis(value, name='value')[source]

Validate a satoshi amount.

Rules:
  • Must be a plain int (not bool, not float).

  • Must be > 0.

  • Must not exceed BTC_MAX_SATS (21M BTC).

The cap is IMPORTED, not written out. It used to be a bare 2_100_000_000_000_000 here — a second, unnamed copy of a chain’s MAX_MONEY, which is the exact shape of a bug this SDK has already had: Bitcoin’s supply cap was applied to a RADIANT amount, where the real limit is a thousand times larger (RADIANT_MAX_PHOTONS), and a single legitimate UTXO above 21,000,000 RXD then raised inside a list comprehension and took every sibling UTXO on that address down with it. An anonymous literal is the copy that gets pasted onto the wrong chain, because nothing about it says which chain it belongs to.

Raises:

ValidationError – on any violation.

Parameters:
Return type:

None