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:
objectBtcBroadcasterbacked by a Bitcoin CoresendrawtransactionRPC.Intended for the regtest milestone (a local node). Reuses the injected
rpc(method, params)coroutine so it shares transport/auth with aBitcoinCoreRpcSourcerather than opening a second session. Idempotent: an “already known” node response is mapped to the tx’s own txid as success.
- class pyrxd.btc_wallet.BitcoinTaprootLeg[source]¶
Bases:
objectThe 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_utxomust holdbtc_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(seerequire_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:
network (str)
taker_keypair (BtcKeypair)
funding_utxo (BtcUtxo)
maker_claim_pubkey_xonly (bytes)
broadcaster (BtcBroadcaster)
funding_reader (BtcFundingReader)
refund_to_scriptpubkey (bytes)
claim_to_scriptpubkey (bytes)
policy (FundingPolicy | None)
maker_claim_privkey (bytes | None)
audit_cleared (bool)
- 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_txre-verifiessha256(p)opens the leaf hashlock before signing.- Parameters:
locator (BtcHtlcLocator)
preimage (bytes)
- Return type:
- 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_bytespwas scraped from (taproot.btc_txid_from_raw) — so an attacker can’t revealpin 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.
- derive_funding_scriptpubkey(terms)[source]¶
The funding SPK the taker independently re-derives from the terms.
- Return type:
- 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:
- 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:
- 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:
- 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
timeoutdeep (mature atfunding 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:
locator (BtcHtlcLocator)
timeout (Timelock)
- Return type:
- scrape_secret(claim_tx_bytes, hashlock)[source]¶
Scrape
pfrom the maker’s claim tx witness (pure; by sha256==H).
- 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 ofpyrxd.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 (revealingp), the taker claims the full asset, and the maker is paid less than the agreed price.What is checked, in order (all fail-closed):
the reader can report a CONFIRMED, UNSPENT output at all (
BtcConfirmedOutputReader); a reader without that capability, or a spent / unconfirmed / unknown outpoint, refuses;the real on-chain
scriptPubKeyequals the HTLC re-derived here fromterms(maker’s claim key + taker’s refund key + H +t_btc). Comparing a supplied locator’s ownscriptpubkey()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;the on-chain value equals
terms.value_amountEXACTLY — under- AND over-funding are both rejected, matchingSwapCoordinator.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 swapvalue_amountisbtc_sats(NegotiatedTermsenforces the equality);the funding is buried
min_confirmationsdeep. PoW finality IS a confirmation depth (seepyrxd.gravity.finality.CounterClaimFinality), and a shallow funding can be reorged out from under the maker AFTER it locks.Noneuses this leg’s configuredmin_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:
- class pyrxd.btc_wallet.BtcBroadcaster[source]¶
Bases:
ProtocolSubmit a raw BTC tx to the network. Composed into the leg (not on the ABC).
broadcastMUST 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)¶
- class pyrxd.btc_wallet.BtcFundingReader[source]¶
Bases:
ProtocolRead 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_satsreturns the value of(txid, vout)as committed on-chain (NOT a self-report), enforcingmin_confirmations(raise/fail-closed if shallower).confirmationsis the symmetric confirmation-depth reader (mirrorsRadiantChainIO.confirmations) the reorg gate consumes.txid_ofresolves 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 todecoderawtransaction.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
pwas 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_rawis 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).
- class pyrxd.btc_wallet.BtcHtlc[source]¶
Bases:
objectThe 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_fundingproduces aBtcHtlcLocatoronce the funding outpoint + amount are known.- __init__(script_tree, internal_key, control_block_claim, control_block_refund, network)¶
- Parameters:
script_tree (ScriptTree)
internal_key (bytes)
control_block_claim (bytes)
control_block_refund (bytes)
network (str)
- Return type:
None
- with_funding(outpoint, amount_sats)[source]¶
- Parameters:
outpoint (BtcOutpoint)
amount_sats (int)
- Return type:
- script_tree: ScriptTree¶
- class pyrxd.btc_wallet.BtcHtlcLocator[source]¶
Bases:
objectThe 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:
funding_outpoint (BtcOutpoint)
script_tree (ScriptTree)
control_block_claim (bytes)
control_block_refund (bytes)
internal_key (bytes)
amount_sats (int)
network (str)
- Return type:
None
- funding_outpoint: BtcOutpoint¶
- script_tree: ScriptTree¶
- class pyrxd.btc_wallet.BtcKeypair[source]¶
Bases:
objectA 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:
- __init__(_privkey, pubkey_bytes, p2pkh_address, p2wpkh_address, p2sh_p2wpkh_address, p2tr_address, pkh, p2sh_hash, p2tr_output_key, network='bc')¶
- class pyrxd.btc_wallet.BtcOutpoint[source]¶
Bases:
objectA funding outpoint (txid big-endian hex as shown by explorers, + vout).
- class pyrxd.btc_wallet.BtcPaymentTx[source]¶
Bases:
objectResult of build_payment_tx.
- __init__(tx_hex, txid, fee_sats, change_sats, input_type, output_type)¶
- class pyrxd.btc_wallet.ScriptTree[source]¶
Bases:
objectA 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)¶
- class pyrxd.btc_wallet.TimeUnit[source]¶
Bases:
EnumThe unit a
Timelockis 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:
objectA unit-tagged relative timelock (BIP68/112 CSV).
- 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 atblock_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 —
valueblocks, priced at the caller’s measuredblock_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_sis 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.
- 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:
- normalize_to(unit, *, block_interval_s)[source]¶
Return an equivalent
Timelockinunit.block_interval_sis 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.
- 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_policysupplies the min-relay rate the assembled transaction is checked against, defaulting toDEFAULT_BITCOIN_DEADLINE_FEE_POLICY(Bitcoin Core’sDEFAULT_MIN_RELAY_TX_FEE, 1 sat/vB). Pass a policy built from the target node’s owngetnetworkinfo/relayfeewhen it advertises something else. RaisesInsufficientFundsErrorbelow that floor — see_assert_spend_fee_clears_relay_floor().
- 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.
- 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_policysupplies the min-relay rate the assembled transaction is checked against, defaulting toDEFAULT_BITCOIN_DEADLINE_FEE_POLICY(Bitcoin Core’sDEFAULT_MIN_RELAY_TX_FEE, 1 sat/vB). Pass a policy built from the target node’s owngetnetworkinfo/relayfeewhen it advertises something else. RaisesInsufficientFundsErrorbelow that floor — see_assert_payment_fee_clears_relay_floor().- Parameters:
- Return type:
- 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_policysupplies 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.
- 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:
- pyrxd.btc_wallet.keypair_from_wif(wif, network='bc')[source]¶
Load keypair from WIF string (for testing/recovery).
- Parameters:
- Return type:
- 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_clearedparameter are kept so existing callers continue to work.
- pyrxd.btc_wallet.scrape_secret(claim_tx_bytes, hashlock)[source]¶
Extract the preimage
pfrom a claim tx by matchingsha256(p)==H.Matches over EVERY witness push of EVERY input — never by positional offset (the C-PARSER lesson). Returns the 32-byte preimage. Raises
ValidationErrorif no witness push hashes toH(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.
- 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_000here — 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