pyrxd (top-level)

pyrxd — Python SDK for the Radiant (RXD) blockchain.

Provides transaction building, HD wallet, Glyph token protocol (NFT/FT/dMint), Gravity cross-chain atomic swaps, SPV verification, and ElectrumX networking.

Quickstart:

from pyrxd import GlyphBuilder, GlyphMetadata, GlyphProtocol
from pyrxd import RxdSdkError, ValidationError
Subpackages:

pyrxd.glyph — Glyph token protocol (NFT, FT, dMint, mutable, V2) pyrxd.swap — Same-chain partial-transaction swaps (RXD/token) pyrxd.gravity — Cross-chain (BTC/ETH↔RXD) HTLC atomic swaps pyrxd.security — Typed secrets, error hierarchy, secure RNG pyrxd.hd — BIP-32/39/44 HD wallet pyrxd.network — ElectrumX client, BTC data sources pyrxd.spv — SPV chain/payment verification pyrxd.transaction — Transaction building and serialization pyrxd.script — Script types and evaluation pyrxd.devnet — Local regtest dev node (see pyrxd regtest)

Implementation note — lazy top-level re-exports:

The public names listed in __all__ are resolved on first attribute access via PEP 562 __getattr__, not eagerly imported at package load time. This keeps import pyrxd (or any submodule) cheap, and crucially keeps the import graph minimal for callers that only touch a small slice of the SDK — most importantly the browser-hosted inspect tool, which imports pyrxd.glyph.inspect and would otherwise transitively load coincurve (no Pyodide wheel), aiohttp, websockets, etc.

Typing tools (mypy, IDE introspection, dir()) read the _LAZY_EXPORTS mapping and the __all__ list; runtime users see the same names with no behaviour change.

class pyrxd.ActiveOffer[source]

Bases: object

State of a live Gravity MakerOffer on Radiant.

Returned by GravityMakerSession.create_offer() and required by all subsequent lifecycle methods.

offer

The original GravityOffer covenant parameters.

Type:

pyrxd.gravity.types.GravityOffer

maker_offer_result

Raw tx details from build_maker_offer_tx.

Type:

pyrxd.gravity.types.MakerOfferResult

offer_txid

Radiant txid of the confirmed MakerOffer funding output.

Type:

str

offer_vout

Output index of the MakerOffer P2SH UTXO (always 0).

Type:

int

offer_photons

Photons locked in the MakerOffer P2SH output.

Type:

int

__init__(offer, maker_offer_result, offer_txid, offer_vout, offer_photons)
Parameters:
Return type:

None

offer: GravityOffer
maker_offer_result: MakerOfferResult
offer_txid: str
offer_vout: int
offer_photons: int
class pyrxd.AddressRecord[source]

Bases: object

AddressRecord(address: ‘str’, change: ‘int’, index: ‘int’, used: ‘bool’)

__init__(address, change, index, used)
Parameters:
Return type:

None

address: str
change: int
index: int
used: bool
class pyrxd.Asset[source]

Bases: object

One side of a trade: plain RXD, or a Glyph fungible token.

amount is in photons. For an FT this is also the token-unit count (Radiant convention: 1 photon = 1 FT unit). ref is the FT’s genesis/commit outpoint (the permanent token identity) and is required for — and only for — kind == "ft".

__init__(kind, amount, ref=None)
Parameters:
Return type:

None

classmethod from_dict(d)[source]
Parameters:

d (dict)

Return type:

Asset

ref: GlyphRef | None = None
to_dict()[source]
Return type:

dict

kind: Literal['rxd', 'ft']
amount: int
class pyrxd.CappedFeeWalletSource[source]

Bases: object

A capped FeeUtxoSource over a fixed pre-funded pool.

Parameters:
  • pool – The pre-funded inventory: small plain-RXD FeeInput UTXOs the capped-pool wallet owns. Each must be a bare P2PKH UTXO whose pkh matches its own WIF (validated). Must be non-empty and free of duplicate outpoints (a duplicate would double-spend).

  • total_cap_photons – Hard cumulative ceiling on dispensed value. Dispensing stops once the next input would push the running total over this — before handing it out.

  • max_per_input_photons – Optional per-input ceiling. If given, construction fails when any pool UTXO exceeds it, keeping the “a fee input is small” invariant structural rather than assumed.

__init__(pool, *, total_cap_photons, max_per_input_photons=None)[source]
Parameters:
Return type:

None

property dispensed_photons: int

Cumulative value handed out so far.

property funded_photons: int

Total value of the pre-funded pool. This is the ceiling only if the pool key is isolated from the operator’s main wallet (a deployment property this class cannot verify — see the module docstring and the design note’s residuals).

next_fee_input()[source]

Dispense (commit) the next pool UTXO.

Raises FeePoolExhaustedError — fail-closed — when the pool is empty or the next input would exceed total_cap_photons. Dispense-once: the returned UTXO is never returned again.

Return type:

FeeInput

property remaining_inputs: int

Count of pool UTXOs not yet dispensed (physical inventory; some may be blocked by the cap — see remaining_photons for the actually-spendable budget).

property remaining_photons: int

Photons that next_fee_input() will actually dispense from here — the in-order prefix of remaining inputs that fits under the cap. Dispensing is in-order and stops at the first input that would exceed the cap (head-of-line), so this is 0 once the next input no longer fits, giving a tower an honest “page now” signal that matches dispense behaviour.

property total_cap_photons: int

The configured cumulative software ceiling.

class pyrxd.CoordinatorConfig[source]

Bases: object

Tunables for SwapCoordinator.

__init__(margin_policy, maker_stall_safety_window_blocks=6, min_ref_confirmations=6, accept_nondurable_seen=False, accept_estimated_eth_margins=False, min_credential_confirmations=6)
Parameters:
  • margin_policy (MarginPolicy)

  • maker_stall_safety_window_blocks (int)

  • min_ref_confirmations (int)

  • accept_nondurable_seen (bool)

  • accept_estimated_eth_margins (bool)

  • min_credential_confirmations (int)

Return type:

None

accept_estimated_eth_margins: bool = False
accept_nondurable_seen: bool = False
maker_stall_safety_window_blocks: int = 6
min_credential_confirmations: int = 6
min_ref_confirmations: int = 6
margin_policy: MarginPolicy
class pyrxd.CounterChainLeg[source]

Bases: ABC

Abstract counter-chain HTLC leg (BTC Taproot / ETH contract / future chains).

Implementations hold their own signing key material (as the repo’s PrivateKeyMaterial, never plaintext) and a chain RPC client. locator is a chain-specific durable record (BtcHtlcLocator / EthHtlcLocator) carrying no secret. claim_artifact is chain-specific opaque bytes/handle the leg knows how to read the preimage from. All methods fail closed (raise) rather than silently pass.

abstractmethod async claim(locator, preimage)[source]

Claim the counter-chain value with the preimage (revealing it on that chain).

Parameters:
Return type:

Any

abstractmethod async fund(terms)[source]

Lock the counter-chain value into a fresh HTLC; return its durable locator.

MUST NOT return a locator until the funding is confirmed/irreversible enough that treating the leg as “locked” is safe (e.g. ETH waits for the deploy tx status==1).

Parameters:

terms (Any)

Return type:

Any

abstractmethod async is_final(tx_or_locator)[source]

True once the referenced claim/lock is final on the counter-chain (BTC depth / ETH finalized). The asset side MUST NOT be treated as irreversibly settled until the counter-chain claim is final (a pre-finality reorg could un-reveal p).

Parameters:

tx_or_locator (Any)

Return type:

bool

abstractmethod recover_secret(claim_artifact, hashlock)[source]

Recover the preimage p (sha256(p)==hashlock) from a claim artifact, matching over ALL candidate windows by hash (never by offset). Fail closed if absent.

Parameters:
Return type:

bytes

abstractmethod async refund(locator)[source]

Reclaim the counter-chain value after the locator’s timeout. Unilateral (no counterparty signature). The relative/absolute timeout is carried by locator.

Parameters:

locator (Any)

Return type:

Any

abstractmethod async verify_funded(locator, *, expected_amount_wei)[source]

Pre-asset-lock gate: assert the on-chain HTLC matches the negotiated terms (program logic + hashlock + recipients + timeout + funded amount). Raise on any mismatch — the asset side MUST NOT be locked against an unverified counter-chain HTLC (defends ‘taker funded an attacker/under-funded contract’).

Parameters:
  • locator (Any)

  • expected_amount_wei (int)

Return type:

None

class pyrxd.EthLeg[source]

Bases: object

Coordinator-shaped ETH counter leg.

Parameters:
  • contract_leg – The web3-backed EthHtlcContractLeg (already holding the rpc + signing key + artifact + chain id).

  • network – Network tag (e.g. "sepolia", "anvil", "mainnet"). Read by the coordinator’s _leg_is_value_bearing gate, and gated by require_audit_cleared.

  • refund_to (claim_to /) – The maker’s ETH address (receives ETH on claim(p)) and the taker’s ETH address (receives ETH on refund()). These live on the leg, not in NegotiatedTerms.

  • eth_timeout_unix_s – The absolute negotiated ETH refund deadline (the contract immutable timeout).

  • audit_cleared – Fail-closed audit gate (same discipline as the BTC leg): a non-test network refuses to run unless an external audit of the ETH bridge has cleared it and this is set True.

__init__(*, contract_leg, network, claim_to, refund_to, eth_timeout_unix_s, audit_cleared=False)[source]
Parameters:
Return type:

None

async assert_claim_provenance(tx_hash, *, contract_address, preimage)[source]

Provenance gate (R6) — the ETH analogue of the BTC funding-outpoint check: the claim tx must target THIS swap’s HTLC contract instance and emit the revealed secret p from it (tx.to + a successful receipt + a Claimed(p) log from the contract). Binds the SECRET p, not the public H. Fail-closed; see EthHtlcContractLeg.assert_claim_provenance().

Parameters:
  • tx_hash (str)

  • contract_address (str)

  • preimage (bytes)

Return type:

None

async claim(locator, preimage)[source]
Parameters:
  • locator (EthHtlcLocator)

  • preimage (bytes)

Return type:

str

async claim_finality_verdict(tx_hash)[source]

The point-in-time ETH finality verdict (FINAL once at/under the finalized checkpoint, else NOT_YET_FINAL_LIVE) the reorg gate consumes.

Parameters:

tx_hash (str)

Return type:

CounterClaimFinality

derive_funding_scriptpubkey(terms)[source]
Return type:

bytes

expected_locator(terms, *, contract_address, deploy_tx_hash=None)[source]

The locator the MAKER expects for a correctly-funded counter HTLC at contract_address.

Built entirely from the maker’s OWN payout config (claim_to/refund_to/ eth_timeout_unix_s) + the negotiated terms (hashlock, value_amount, chain id) — it does NOT trust any counterparty-supplied locator. verify_counterparty_funded() checks the on-chain contract at contract_address matches THIS expected locator, which is what binds the taker-deployed contract to ‘pays the maker on claim, refunds the taker, on the agreed H/amount/deadline’. deploy_tx_hash is informational (not bound on-chain).

Parameters:
  • contract_address (str)

  • deploy_tx_hash (str | None)

Return type:

EthHtlcLocator

async fetch_claim_artifacts(tx_hash)[source]

Fetch the candidate byte blobs (claim calldata + receipt log data) for scrape_secret(). Works on a reverted-but-mined claim too.

Parameters:

tx_hash (str)

Return type:

list[bytes]

async fund(terms)[source]

Deploy + fund the ETH HTLC from the negotiated terms, then run the post-deploy binding gate (verify_funded) BEFORE returning — so the coordinator never tells the maker to lock RXD against a wrong/attacker/under-funded contract.

DEPLOY-THEN-VERIFY ATOMICITY (audit completeness): unlike the BTC P2TR path (whose funding address is pre-derived and verified BEFORE any broadcast), an ETH HTLC contract does not exist until it is deployed, so verify_funded necessarily runs AFTER the deploy+fund has already put value on-chain. If verify fails (wrong immutables, balance mismatch, attacker logic), the ETH is locked in a contract the coordinator rejects. The loss is BOUNDED and RECOVERABLE: the contract pays its immutable refundee (the taker) via refund() after timeout. To make the stranded deploy recoverable WITHOUT a chain rescan, we stash the deployed locator on self.last_funded_locator BEFORE verify — so a caller that sees fund raise still has the contract address to drive the timelock refund. (A full coordinator-record-level recovery is a Phase-4 item.)

Return type:

EthHtlcLocator

locked_amount(locator)[source]

The funded amount the coordinator binds to terms.value_amount — wei for ETH.

Parameters:

locator (EthHtlcLocator)

Return type:

int

promised_funding_scriptpubkey(terms)[source]
Return type:

bytes

async refund(locator, timeout=None)[source]
Parameters:

locator (EthHtlcLocator)

Return type:

str

scrape_secret(claim_artifacts, hashlock)[source]

Recover p from the maker’s ETH claim — fail-closed by sha256 == H over the candidate blobs (calldata + log data) the caller fetched via fetch_claim_artifacts(). Pure (no network), mirroring the BTC leg’s pure witness scrape.

Parameters:
Return type:

bytes

async verify_counterparty_funded(contract_address, terms, *, block_identifier=None)[source]

MAKER-side fail-closed gate (red-team CRITICAL fix): verify the TAKER-deployed ETH HTLC at contract_address binds to the maker’s EXPECTED terms BEFORE the maker locks the asset.

The taker deploys the ETH HTLC FIRST and the maker locks RXD SECOND, so the maker MUST independently verify the on-chain contract (claimant==maker, refundee==taker, hashlock==H, timeout, funded balance==amount) — otherwise a hostile taker deploys claimant=self or underfunds and the honest maker locks the asset for nothing (one-sided maker loss). We build the EXPECTED locator from the maker’s own config (NOT a taker-supplied one) and run EthHtlcContractLeg.verify_funded() against the contract at contract_address — any mismatch raises. Returns the verified locator (for the maker’s subsequent claim).

block_identifier (red-team HIGH TOCTOU): the coordinator re-runs this at RXD-lock time pinned to 'finalized' so a reorg cannot replace the taker’s deploy after the maker verified it; see SwapCoordinator.post_asset_lock_revalidate().

Parameters:
  • contract_address (str)

  • block_identifier (str | int | None)

Return type:

EthHtlcLocator

class pyrxd.EvmChain[source]

Bases: object

One EVM-equivalent counter chain the ETH leg machinery can run against.

chain_id pins the chain everywhere it matters: EthRpc(expected_chain_id=...) refuses a node on the wrong chain, EthHtlcContractLeg(chain_id=...) signs with EIP-155 replay protection, and the durable EthHtlcLocator records it. network is the tag EthLeg(network=...) reads for the value-bearing/audit gates. finalization_window_s seeds MarginPolicy.eth_finalization_window_s.

__init__(name, chain_id, network, finalization_window_s)
Parameters:
  • name (str)

  • chain_id (int)

  • network (str)

  • finalization_window_s (int)

Return type:

None

name: str
chain_id: int
network: str
finalization_window_s: int
class pyrxd.FundingInput[source]

Bases: object

A taker-owned UTXO used to fund the maker’s receive + fee (and/or to pay an FT the maker wants).

source_tx is the taker’s own previous transaction, so its value/script are trusted (the taker controls it). key signs it.

__init__(source_tx, vout, key)
Parameters:
Return type:

None

source_tx: Transaction
vout: int
key: PrivateKey
class pyrxd.GlyphBuilder[source]

Bases: object

Build unsigned Glyph transactions.

Separate commit and reveal methods — caller is responsible for:

  1. Signing the commit tx and broadcasting it.

  2. Waiting for confirmation.

  3. Passing the confirmed commit txid to the reveal method.

  4. Signing the reveal tx (via Transaction + PrivateKey).

Method selection guide (N9 — surface grew to 12 methods across 5 protocols)

Minting (commit → reveal)

Goal

Protocol tag(s)

Reveal method

Mint a singleton NFT

[NFT]

prepare_reveal()

Mint a plain FT

[FT]

prepare_ft_deploy_reveal()

Mint a dMint FT

[FT, DMINT]

prepare_dmint_deploy() (3 txs)

Mint a mutable NFT

[NFT, MUT]

prepare_mutable_reveal()

Mint a collection

``[NFT,CONTAINER]`

prepare_container_reveal()

Mint a WAVE name

[NFT,MUT,WAVE]

prepare_wave_reveal()

For every token type the first step is the same: call prepare_commit() (which derives the commit script from the metadata protocol list automatically). Only the reveal step differs.

Transfers (no commit needed)

  • NFT transfer: build_nft_transfer_tx()

  • FT transfer: build_ft_transfer_tx() (or FtUtxoSet in glyph/ft.py)

Low-level (rarely called directly)

  • prepare_reveal() — generic reveal; is_nft picks singleton vs FT reftype

  • build_reveal_scripts() — alternate reveal entry that returns scripts, not params

  • build_transfer_locking_script() — bare FT lock without constructing a tx

  • build_contract_script() — MUT contract script for mutable NFT reveals

build_ft_transfer_tx(params)[source]

Build a signed FT transfer transaction enforcing conservation.

Thin delegator to FtUtxoSet.build_transfer_tx() — the real logic (selection, two-pass fee, conservation) lives there so the API surface is available both at the builder level and directly on a UTXO-set instance.

Parameters:

params (FtTransferParams) – FtTransferParams — see dataclass docstring.

Returns:

FtTransferResult — signed tx + scripts + fee.

Raises:

ValueError – same conditions as FtUtxoSet.build_transfer_tx() (insufficient FT balance, insufficient RXD for fee + dust).

Return type:

FtTransferResult

build_nft_transfer_tx(params)[source]

Build a signed NFT transfer transaction.

Spends an existing NFT UTXO (standard P2PKH scriptSig unlock: <sig> <pubkey>) and creates a new NFT output locked to new_owner_pkh. The 36-byte ref is preserved across the transfer — it’s extracted from the input’s NFT script and written into the new output’s NFT script unchanged.

Fee calculation is two-pass: build a trial tx, sign it to measure actual serialised size, then rebuild with the final value = input_value - size*fee_rate. The trial signature is discarded (reset unlocking_script = None before final sign) so the final tx carries a signature over the final outputs, not the trial ones.

Parameters:

params (TransferParams) – TransferParams — see dataclass docstring

Returns:

TransferResult — signed tx, new locking script, ref, fee

Raises:
Return type:

TransferResult

build_transfer_locking_script(ref, new_owner_pkh, is_nft)[source]

Build the locking script for a transfer output.

Parameters:
Return type:

bytes

prepare_commit(params)[source]

Prepare the commit transaction parameters.

Returns the commit locking script + CBOR bytes + estimated fee. Caller must build, sign, and broadcast the actual transaction.

The commit script’s OP_REFTYPE_OUTPUT check is derived from metadata.protocol: NFT (2 in protocol) produces an OP_2/SINGLETON-expecting commit; any other protocol mix (FT, dMint FT, data, etc.) produces an OP_1/NORMAL-expecting commit. This means the caller does not hand-pick refType — the metadata drives it. Prior versions forced every commit to NFT shape; see build_commit_locking_script for the fix note.

Parameters:

params (CommitParams)

Return type:

CommitResult

prepare_container_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, child_ref=None)[source]

Prepare scripts for a CONTAINER reveal.

A container is an NFT with an additional OP_PUSHINPUTREF <child_ref> prefix that links it to a child token ref. When child_ref is None the container is created empty (no child ref in locking script).

Protocol field must include GlyphProtocol.CONTAINER (7).

Parameters:
Return type:

ContainerRevealScripts

prepare_dmint_deploy(params, *, allow_v2_deploy=True)[source]

Prepare a dMint token deploy.

Dispatches on the type of params:

  • DmintV1DeployParams → returns DmintV1DeployResult. V1 is the only format on Radiant mainnet today (see GLYPH at a443d9df…878b). Two-tx deploy: commit + reveal (the reveal directly creates params.num_contracts parallel contract UTXOs).

  • DmintV2DeployParams → returns DmintV2DeployResult. V2 is consensus-proven on regtest + mainnet (#219) and now deploys by default (allow_v2_deploy=True). A soft UserWarning is emitted if the caller explicitly passes allow_v2_deploy=False so the historical opt-out path stays observable without blocking.

Parameters:
  • params (DmintV1DeployParams | DmintV2DeployParams) – Either DmintV1DeployParams (V1 deploy) or DmintV2DeployParams (V2 deploy). The deprecated DmintFullDeployParams is accepted (it’s a subclass of DmintV2DeployParams) but emits a DeprecationWarning at construction time.

  • allow_v2_deploy (bool) – Retained for backward-compatibility; defaults to True (V2 deploys by default). Ignored for V1.

Returns:

V1 or V2 result, matching the param type via @overload.

Raises:

ValidationError – Various per-version invariants — see _prepare_dmint_v1_deploy() and the V2 implementation below for specifics.

Return type:

DmintV1DeployResult | DmintV2DeployResult

prepare_ft_deploy_reveal(commit_txid, commit_vout, commit_value, cbor_bytes, premine_pkh, premine_amount)[source]

Prepare reveal scripts + premine amount for an FT deploy.

Thin convenience wrapper around prepare_reveal() for the FT-deploy-with-premine flow: the reveal produces one FT output carrying the full issued supply to premine_pkh, and its outpoint becomes the permanent token ref.

Caller still constructs the actual transaction. The returned premine_amount is what vout[0].value must be on the reveal tx — typically the full supply for a premine-only deploy (no covenant UTXO). Radiant FT convention: 1 photon = 1 FT unit, so premine_amount is the supply in whole units.

No dMint-specific logic here. The cbor_bytes already encode whatever protocol markers the caller chose — dMint FT ([1,4]), plain FT ([1]), or any other combination — via GlyphMetadata. pyrxd treats the protocol markers as caller-owned; classification happens at the indexer layer.

Parameters:
  • commit_txid (str)

  • commit_vout (int)

  • commit_value (int)

  • cbor_bytes (bytes)

  • premine_pkh (Hex20)

  • premine_amount (int)

Return type:

FtDeployRevealScripts

prepare_mutable_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh)[source]

Prepare scripts for a MUT (mutable NFT) reveal.

Returns the two output locking scripts the caller must place in the reveal tx: - nft_script: 63-byte NFT singleton (token the owner holds) - contract_script: 174-byte mutable contract UTXO (holds state)

The reveal scriptSig suffix is also returned; the caller prepends <sig> <pubkey> to form the full scriptSig.

Protocol field in cbor_bytes must include GlyphProtocol.MUT (5). Use GlyphMetadata(protocol=[GlyphProtocol.NFT, GlyphProtocol.MUT]).

Parameters:
Return type:

MutableRevealScripts

prepare_reveal(params)[source]

Prepare the reveal transaction scripts.

Returns locking script + scriptSig suffix. Caller must build, sign, and broadcast the actual transaction.

Parameters:

params (RevealParams)

Return type:

RevealScripts

prepare_wave_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, name)[source]

Prepare scripts for a WAVE (on-chain naming) reveal.

WAVE extends MUT with a name field in the CBOR payload. Protocol field must include GlyphProtocol.WAVE (11).

name must be non-empty printable ASCII, max 255 characters. The name is validated here but must already be embedded in cbor_bytes by the caller via either attrs["name"] (the Photonic-compatible canonical shape — required for resolution against RXinDexer and other indexers) or top-level name (legacy pyrxd shape, accepted for backwards compatibility but not indexer-visible).

Photonic-compatible CBOR shape (canonical, see Photonic Wallet packages/lib/src/wave.ts):

{
    "p": [2, 5, 11],
    "attrs": {
        "name": "alice.rxd",
        "domain": "rxd",
        "target": "<radiant_address>",
        "target_type": "address"
    }
}

Use build_wave_attrs() (or pyrxd.glyph.wave.build_wave_metadata()) to construct the canonical shape; passing a top-level name field still works but emits a token RXinDexer will not index.

Protocol requirement: [NFT(2), MUT(5), WAVE(11)].

Parameters:
Return type:

MutableRevealScripts

class pyrxd.GlyphInspector[source]

Bases: object

Parse raw transaction bytes to find Glyph outputs. Pure — no network access.

extract_reveal_metadata(scriptsig)[source]

Parse a reveal TX scriptSig to extract CBOR metadata.

scriptSig format: <sig> <pubkey> <"gly"> <CBOR>. Returns None if this is not a reveal scriptSig (or if the CBOR is malformed / unrecognised).

Catches Exception broadly because every call site here crosses a trust boundary: scriptSigs from network-fetched txs are attacker- controlled, and the CBOR decoder + push-data walker may raise anything from ValidationError to cbor2.CBORDecodeError to IndexError on truncated input. Returning None is the contract callers expect.

Parameters:

scriptsig (bytes)

Return type:

GlyphMetadata | None

find_glyphs(tx_outputs)[source]

Given list of (satoshis, script_bytes) outputs, return detected Glyphs.

Detects NFT singletons, FT locks, mutable NFTs, and dMint contract outputs. Plain P2PKH and unrecognised scripts are silently skipped. Commit-output classification lives outside find_glyphs because a commit has no meaningful ref until its reveal lands.

Parameters:

tx_outputs (list[tuple[int, bytes]])

Return type:

list[GlyphOutput]

find_reveal_metadata(scriptsigs)[source]

Walk every input scriptSig and return the first reveal metadata found.

Returns (input_index, metadata) for the first input whose scriptSig embeds a gly marker followed by parseable CBOR; None if no input does. Distinct from extract_reveal_metadata() (which checks a single scriptSig) — diagnostic callers want to know which input carried the metadata, and that the inspector looked beyond input 0.

Parameters:

scriptsigs (list[bytes])

Return type:

tuple[int, GlyphMetadata] | None

parse_mint_scriptsig(scriptsig)[source]

Decode a dMint mint-claim scriptSig into its 4 canonical pushes.

A V1/V2 dMint mint claim spends the contract UTXO with a scriptSig of the form:

V1 (nonce_width=4): <0x04 nonce(4)> <0x20 inputHash(32)> <0x20 outputHash(32)> <OP_0>  → 72 bytes
V2 (nonce_width=8): <0x08 nonce(8)> <0x20 inputHash(32)> <0x20 outputHash(32)> <OP_0>  → 76 bytes

Where:

  • nonce — little-endian PoW nonce found by the miner.

  • inputHashSHA256d(funding_input_locking_script). NOT a preimage half; the on-chain covenant recomputes SHA256(inputHash || outputHash) from these literal pushes.

  • outputHashSHA256d(OP_RETURN_msg_script at vout[2]).

  • OP_0 — the sentinel push the V1/V2 covenant requires.

Verified against mainnet V1 mint 146a4d68…f3c and the V1 mint c9fdcd34…e530.

Returns a dict with nonce_hex, input_hash, output_hash, version_hint ("v1" | "v2" | None), and scriptsig_length — or None if the scriptSig doesn’t match the canonical 4-push shape.

Catches Exception broadly because every call site crosses a trust boundary: scriptSigs from network-fetched txs are attacker- controlled. Non-mint inputs (P2PKH funding inputs, plain RXD spends, etc.) return None.

Parameters:

scriptsig (bytes)

Return type:

dict | None

class pyrxd.GlyphMetadata[source]

Bases: object

CBOR payload for a Glyph token.

__init__(protocol, name='', ticker='', description='', token_type='', main=None, attrs=<factory>, loc='', loc_hash='', decimals=0, image_url='', image_ipfs='', image_sha256='', v=None, dmint_params=None, creator=None, royalty=None, policy=None, rights=None, created='', commit_outpoint='')
Parameters:
Return type:

None

commit_outpoint: str = ''
created: str = ''
creator: GlyphCreator | None = None
decimals: int = 0
description: str = ''
dmint_params: DmintCborPayload | None = None
classmethod for_dmint_ft(ticker, name, decimals=0, description='', image_url='', image_ipfs='', image_sha256='', protocol=None, dmint_params=None)[source]

Construct GlyphMetadata for a dMint-marked FT deploy.

Pass dmint_params (a DmintCborPayload) to embed the dMint configuration object in the token metadata. Indexers and wallets use this to display mining parameters without parsing the contract script.

Sets v=2 automatically when dmint_params is provided.

Parameters:
Return type:

GlyphMetadata

image_ipfs: str = ''
image_sha256: str = ''
image_url: str = ''
loc: str = ''
loc_hash: str = ''
main: GlyphMedia | None = None
name: str = ''
policy: GlyphPolicy | None = None
rights: GlyphRights | None = None
royalty: GlyphRoyalty | None = None
ticker: str = ''
to_cbor_dict()[source]

Build the dict that gets CBOR-encoded (excluding ‘gly’ marker).

Return type:

dict

token_type: str = ''
v: int | None = None
protocol: list[int]
attrs: dict[str, str]
class pyrxd.GlyphProtocol[source]

Bases: IntEnum

__new__(value)
FT = 1
NFT = 2
DAT = 3
DMINT = 4
MUT = 5
BURN = 6
CONTAINER = 7
ENCRYPTED = 8
TIMELOCK = 9
AUTHORITY = 10
WAVE = 11
class pyrxd.GlyphRef[source]

Bases: object

36-byte Glyph reference: txid (reversed LE) + vout (4-byte LE).

__init__(txid, vout)
Parameters:
Return type:

None

classmethod from_bytes(data)[source]

Parse 36-byte wire format.

Parameters:

data (bytes)

Return type:

GlyphRef

classmethod from_contract_hex(contract_hex)[source]

Parse a 72-char contract id string as displayed in Radiant explorers.

The Glyph contract id concatenates the display-order txid (64 hex chars) with the big-endian-encoded vout (8 hex chars). Both halves are written in human-readable order so the whole string reads naturally — the trailing 00000004 decodes to 4:

b45dc453befb589a...c380eb31deaf96a2a8 00000004
└────────── txid (display order) ───┘ └─ vout BE ─┘  (= 4)

Equivalent forms:

  • from_contract_hex("b45dc4...a2a800000004")

  • GlyphRef(txid=Txid("b45dc4...a2a8"), vout=4)

Warning

This is the explorer / UI display form, not the on-chain wire form. from_bytes() parses the wire form used inside locking scripts, where the txid bytes are reversed and the vout is encoded little-endian. If you have raw bytes pulled out of a script, use from_bytes(). Use this method only when you have a contract id in the form a Radiant explorer or wallet UI shows it. Mixing them will silently produce a wrong-vout ref.

Parameters:

contract_hex (str)

Return type:

GlyphRef

to_bytes()[source]

Encode as 36-byte wire format: txid_reversed + vout_le.

Return type:

bytes

txid: Txid
vout: int
class pyrxd.GlyphScanner[source]

Bases: object

Scan a Radiant address or script_hash for Glyph outputs.

Parameters:

client – An already-connected ElectrumXClient. The scanner does not own the connection lifecycle; callers should use the client as a context manager and pass it in.

__init__(client)[source]
Parameters:

client (ElectrumXClient)

Return type:

None

async scan_address(address)[source]

Return all Glyph outputs currently owned at address.

Parameters:

address (str) – Base58Check-encoded P2PKH address.

Returns:

Typed Glyph objects. metadata is None for transfer outputs (no reveal scriptSig in the origin transaction).

Return type:

List[GlyphNft | GlyphFt]

async scan_script_hash(script_hash)[source]

Return all Glyph outputs for script_hash.

Fetches UTXOs, raw transactions, and (where available) reveal transaction metadata, then constructs typed GlyphNft / GlyphFt objects.

Concurrency: UTXO raw-tx fetches and reveal-metadata fetches both run in parallel via asyncio.gather. Pre-fix (closes ultrareview re-review N17) the reveal-metadata path was inside the per-utxo loop and serialised one round-trip per glyph; for a 100-glyph wallet that meant ~100x the latency of the now- batched version.

Parameters:

script_hash (Hex32 | bytes | str)

Return type:

list[GlyphNft | GlyphFt]

class pyrxd.GravityMakerSession[source]

Bases: object

Manage the full lifecycle of a Gravity BTC↔RXD atomic swap offer.

This class handles the Maker’s side of the swap:

  1. Build and broadcast the MakerOffer tx (create_offer).

  2. Poll for the Taker’s claim (wait_for_claim).

  3. Broadcast a cancel tx if the Taker never claims (cancel_offer).

  4. Query current state (check_status).

Parameters:
  • rxd_client – Connected ElectrumXClient for Radiant chain operations (broadcast, query UTXOs).

  • btc_source – A BtcDataSource — used only by subclasses / extensions that need BTC confirmation data. May be None for pure Radiant operations.

  • maker_priv – Maker’s secp256k1 private key wrapped in PrivateKeyMaterial.

  • poll_interval_seconds – Seconds between UTXO polls in wait_for_claim. Default 30.

Examples

Typical Maker flow:

async with ElectrumXClient(["wss://electrumx.example.com"]) as rxd:
    session = GravityMakerSession(rxd_client=rxd, maker_priv=priv)
    params = GravityOfferParams(
        offer=offer,
        funding_txid="...",
        funding_vout=0,
        funding_photons=5_100_000,
        fee_sats=100_000,
    )
    active = await session.create_offer(params)
    claim_txid = await session.wait_for_claim(active, timeout_seconds=3600)
    if claim_txid is None:
        cancel_txid = await session.cancel_offer(active)
__init__(rxd_client, maker_priv, btc_source=None, poll_interval_seconds=30)[source]
Parameters:
Return type:

None

async cancel_offer(offer, fee_sats=1000, maker_address='')[source]

Broadcast the cancel (MakerOffer.cancel()) transaction.

Reclaims the MakerOffer UTXO before the claim deadline using build_cancel_tx. This is only valid if the Taker has NOT yet claimed the UTXO.

Parameters:
  • offer (ActiveOffer) – The ActiveOffer to cancel.

  • fee_sats (int) – Miner fee in photons for the cancel tx. Default 1000.

  • maker_address (str) – Maker’s Radiant P2PKH address to receive the reclaimed photons. Required — must be a valid Radiant address.

Returns:

The cancel tx’s txid.

Return type:

str

Raises:
async check_status(offer)[source]

Return the current status of the offer UTXO.

Queries the Radiant ElectrumX server for the MakerOffer P2SH UTXO.

Returns one of:

  • "open" — UTXO is still unspent (offer not yet claimed).

  • "claimed" — UTXO no longer in unspent set (Taker has claimed).

  • "expired" — claim_deadline has passed and UTXO is unspent

    (Maker can now forfeit).

  • "unknown" — UTXO not found and not yet past deadline

    (may be unconfirmed or already finalized/cancelled).

Parameters:

offer (ActiveOffer) – The ActiveOffer to check.

Returns:

One of "open", "claimed", "expired", "unknown".

Return type:

str

Raises:

NetworkError – On ElectrumX query failure.

async create_offer(offer_params)[source]

Build and broadcast the MakerOffer funding tx.

The offer UTXO is a P2SH output locked to offer_params.offer’s MakerOffer covenant. Once broadcast, the Taker can claim it by spending it with build_claim_tx.

Parameters:

offer_params (GravityOfferParams) – Funding-UTXO details and the GravityOffer covenant.

Returns:

Populated with the resulting txid and UTXO details.

Return type:

ActiveOffer

Raises:
async wait_for_claim(offer, timeout_seconds=3600)[source]

Poll for the Taker’s claim transaction.

Polls get_utxos() on the MakerOffer P2SH script hash. When the UTXO disappears from the unspent set the Taker has claimed it.

This method cannot directly return the claim txid — ElectrumX’s listunspent API only reports which UTXOs are currently unspent. Once the offer UTXO is spent (claimed), we return the offer’s txid as a sentinel so the caller knows which offer was claimed. Callers that need the actual claim txid should fetch the spending tx separately (e.g. via get_transaction on the address history).

Parameters:
  • offer (ActiveOffer) – The ActiveOffer returned by create_offer.

  • timeout_seconds (int) – Maximum seconds to wait. Returns None on timeout.

Returns:

The offer txid (as a claimed-sentinel) on success, or None on timeout.

Return type:

str or None

class pyrxd.GravityOfferParams[source]

Bases: object

Parameters required to create a new Gravity MakerOffer.

These are the funding-UTXO details for the Maker’s side. The GravityOffer itself (covenant bytecode, BTC-side params, etc.) is built externally (e.g. via build_gravity_offer) and passed as offer.

offer

Fully populated GravityOffer with offer_redeem_hex set.

Type:

pyrxd.gravity.types.GravityOffer

funding_txid

Hex txid of the Maker’s P2PKH UTXO being spent to fund the offer.

Type:

str

funding_vout

Output index of the Maker’s funding UTXO.

Type:

int

funding_photons

Value of the Maker’s funding UTXO in photons.

Type:

int

fee_sats

Miner fee in photons for the MakerOffer funding tx.

Type:

int

change_address

Optional Radiant P2PKH address for change output. See build_maker_offer_tx for semantics.

Type:

str | None

__init__(offer, funding_txid, funding_vout, funding_photons, fee_sats, change_address=None)
Parameters:
Return type:

None

change_address: str | None = None
offer: GravityOffer
funding_txid: str
funding_vout: int
funding_photons: int
fee_sats: int
class pyrxd.GravityTrade[source]

Bases: object

Orchestrate a complete Gravity BTC↔RXD atomic swap.

Parameters:
  • radiant_network – Connected ElectrumXClient for Radiant chain operations (broadcast, fetch tx/block).

  • bitcoin_source – A BtcDataSource for Bitcoin chain data (tx fetch, Merkle proof, block headers).

  • config – Optional TradeConfig. Uses defaults if not provided.

Examples

Typical Taker flow:

async with ElectrumXClient(["wss://electrumx.example.com"]) as rxd:
    trade = GravityTrade(radiant_network=rxd, bitcoin_source=btc_src)
    claim = await trade.claim(
        offer=offer,
        offer_txid="...",
        offer_vout=0,
        offer_photons=10_000_000,
        fee_sats=1000,
        taker_privkey=privkey,
    )
    btc_txid = "..."  # broadcast BTC payment externally
    status = await trade.wait_confirmations(btc_txid)
    result = await trade.finalize(
        btc_txid=btc_txid,
        offer=offer,
        claimed_txid=claim.txid,
        claimed_vout=0,
        claimed_photons=claim.output_photons,
        taker_address="...",
        fee_sats=1000,
    )
__init__(*, radiant_network, bitcoin_source, config=None)[source]
Parameters:
Return type:

None

async claim(offer, offer_txid, offer_vout, offer_photons, fee_sats, taker_privkey)[source]

Spend the MakerOffer UTXO, creating a MakerClaimed UTXO.

Broadcasts the claim transaction to the Radiant network and returns a ClaimResult.

The claim transaction requires Taker’s signature (audit 04-S3). build_claim_tx independently verifies the code hash before signing (audit 05-F-13).

Parameters:
  • offer (GravityOffer) – The GravityOffer posted by the Maker.

  • offer_txid (str) – Radiant txid of the MakerOffer funding output.

  • offer_vout (int) – Output index of the MakerOffer UTXO.

  • offer_photons (int) – Value of the MakerOffer UTXO in photons.

  • fee_sats (int) – Radiant miner fee in photons.

  • taker_privkey (PrivateKeyMaterial) – Taker’s secp256k1 private key.

Return type:

ClaimResult

async finalize(btc_txid, offer, claimed_txid, claimed_vout, claimed_photons, taker_address, fee_sats, btc_tx_height=None)[source]

Fetch the BTC SPV proof, verify it, and broadcast the finalize tx.

This method always runs the full SpvProofBuilder verifier chain — there is no way to bypass verification at this level.

Parameters:
  • btc_txid (str) – Bitcoin transaction ID of the Taker’s BTC payment.

  • offer (GravityOffer) – The GravityOffer originally posted by the Maker. Used to construct CovenantParams for SPV proof verification.

  • claimed_txid (str) – Radiant txid of the MakerClaimed UTXO (output of claim()).

  • claimed_vout (int) – Output index of the MakerClaimed UTXO.

  • claimed_photons (int) – Value of the MakerClaimed UTXO in photons.

  • taker_address (str) – Taker’s Radiant P2PKH address to receive the photons.

  • fee_sats (int) – Radiant miner fee in photons.

  • btc_tx_height (int | None) – Optional: Bitcoin block height where btc_txid was confirmed. If not provided, the orchestrator will determine it automatically.

Raises:
Return type:

FinalizeResult

async wait_confirmations(btc_txid, min_confirmations=None)[source]

Poll Bitcoin until btc_txid reaches the required confirmations.

Parameters:
  • btc_txid (str) – Bitcoin transaction ID (64 hex chars, big-endian).

  • min_confirmations (int | None) – Override config.min_btc_confirmations for this call.

Returns:

Always has confirmed=True on return (raises on timeout).

Return type:

ConfirmationStatus

Raises:
class pyrxd.HdWallet[source]

Bases: object

BIP44 HD wallet for Radiant with gap-limit discovery and encrypted persistence.

account

BIP44 account index (usually 0).

Type:

int

coin_type

BIP44 coin type (read-only property; back-store _coin_type is set at construction and never mutated). 512 is SLIP-0044 spec for Radiant (default, also Tangem); 0 matches Photonic and Electron-Radiant; 236 matches pre-#14 pyrxd. Persisted in the wallet file and validated on load. Read-only because mutating it post-construction would desync from the already-derived _xprv and silently route subsequent addresses to a different path (closes SEV-2 red-team finding).

external_tip

Highest derived index on external chain (change=0).

Type:

int

internal_tip

Highest derived index on internal chain (change=1).

Type:

int

addresses

{path_key: AddressRecord} where path_key is f"{change}/{index}".

Type:

dict[str, pyrxd.hd.wallet.AddressRecord]

__init__(_seed, account=0, _coin_type=<factory>, external_tip=0, internal_tip=0, addresses=<factory>)
Parameters:
Return type:

None

account: int = 0
account_xpub()[source]

The account-level xpub (watch-only safe; no private key).

Return type:

Xpub

build_send_max_tx(triples, to_address, *, fee_rate=10000)[source]

Sweep all triples to to_address minus fee. No change output.

Parameters:
Return type:

Transaction

build_send_tx(triples, to_address, photons, *, fee_rate=10000, change_address=None)[source]

Build and sign a P2PKH transfer from HD UTXOs to to_address.

Pure offline operation. Mirrors RxdWallet.build_send_tx() but accepts (utxo, address, privkey) triples so each input is signed by the correct HD-derived key.

change_address defaults to the next unused internal index; callers can override (e.g. to keep change on the external chain for a single-address-style wallet).

Parameters:
  • triples (list[tuple[UtxoRecord, str, PrivateKey]])

  • to_address (str)

  • photons (int)

  • fee_rate (int)

  • change_address (str | None)

Return type:

Transaction

property coin_type: int

BIP44 coin type this wallet was constructed with. Read-only.

Read-only because mutating it post-construction would desync from the already-derived _xprv; subsequent address derivations would still happen at the original path while the persisted JSON would advertise the new path. The __setattr__ override blocks wallet._coin_type = X; the property blocks wallet.coin_type = X.

async collect_spendable(client)[source]

Return (utxo, address, privkey) triples for every UTXO across known addresses.

Address→key mapping is preserved so signing works correctly per UTXO. Falls back gracefully if any per-address fetch fails (the failed address contributes nothing rather than crashing the whole collection — the caller decides whether the resulting balance is enough).

Parameters:

client (ElectrumXClient)

Return type:

list[tuple[UtxoRecord, str, PrivateKey]]

derive_address(change, index)[source]

Derive the P2PKH address at change/index (public seam).

Parameters:
Return type:

str

external_tip: int = 0
classmethod from_mnemonic(mnemonic, passphrase='', account=0, coin_type=None)[source]

Create a fresh wallet from a BIP39 mnemonic.

coin_type selects the BIP44 derivation path:
  • None (default) uses the module-level configured coin type (env var RXD_PY_SDK_BIP44_DERIVATION_PATH, or SLIP-0044’s 512 if unset).

  • 512 is SLIP-0044 Radiant (also Tangem).

  • 0 matches Photonic and Electron-Radiant — pass this when restoring a mnemonic from those wallets.

  • 236 matches pre-#14 pyrxd wallets.

The chosen coin type is recorded on the wallet and persisted in the wallet file; subsequent load() calls validate it.

Parameters:
  • mnemonic (str)

  • passphrase (str)

  • account (int)

  • coin_type (int | None)

Return type:

HdWallet

async get_balance(client)[source]

Return total confirmed + unconfirmed satoshis across all known addresses.

Uses ElectrumXClient.get_balance per address. Call refresh() first to ensure the address set is current.

Parameters:

client (ElectrumXClient)

Return type:

int

async get_utxos(client)[source]

Return all UTXOs across all known addresses.

Parameters:

client (ElectrumXClient)

Return type:

list[UtxoRecord]

internal_tip: int = 0
known_addresses(*, change=None)[source]

Return all known address records, optionally filtered by chain.

Parameters:

change (int | None)

Return type:

list[AddressRecord]

classmethod load(path, mnemonic, passphrase='', coin_type=None)[source]

Load a previously saved wallet from path.

The mnemonic is needed to derive the decryption key. Raises FileNotFoundError if path does not exist — a typo’d path will not silently produce an empty wallet that subsequently overwrites a real wallet on save. Callers that explicitly want the create-on-missing behavior should use load_or_create().

coin_type (optional) is validated against the value persisted in the wallet file. A mismatch raises ValidationError — this catches the silent-empty-wallet failure mode where a default change between pyrxd versions would otherwise have the loaded wallet derive at a different path than it was saved at. Pass None (default) to accept whatever was persisted.

Parameters:
  • path (Path)

  • mnemonic (str)

  • passphrase (str)

  • coin_type (int | None)

Return type:

HdWallet

classmethod load_or_create(path, mnemonic, passphrase='', account=0, coin_type=None)[source]

Load a wallet from path, or build a fresh one if the file is missing.

Spelled separately from load() so the create-on-missing intent is explicit at the call site. A common safety failure with the old single-load API was that a typo in path would produce an empty wallet that subsequently overwrote the real wallet on save.

coin_type applies to both branches: when loading, it is validated against the persisted value; when creating, it is the coin type the new wallet uses.

Parameters:
  • path (Path)

  • mnemonic (str)

  • passphrase (str)

  • account (int)

  • coin_type (int | None)

Return type:

HdWallet

next_receive_address()[source]

Return the first external (change=0) address with no recorded history.

Return type:

str

privkey_for(change, index)[source]

Derive the signing key at change/index (public seam over _privkey_for).

Parameters:
Return type:

PrivateKey

async refresh(client)[source]

Run BIP44 gap-limit scan on both external and internal chains.

Discovers which derived addresses have on-chain history. Stops after _GAP_LIMIT (20) consecutive unused addresses per chain.

Network errors (a transient ElectrumX outage, a server hangup mid-scan) propagate to the caller as NetworkError — previously they were silently treated as “address unused”, which made a funded wallet look empty after a flaky lookup.

Returns the count of newly discovered used addresses.

Parameters:

client (ElectrumXClient)

Return type:

int

save(path)[source]

Encrypt and atomically save wallet state to path.

Atomicity & permissions

Writes via mkstemp + fchmod(0o600) + fsync + os.replace, so:
  • The file is never visible at a wider mode than 0o600 — the mode is set on the fd before any bytes are written.

  • A crash mid-write cannot leave a half-encrypted blob in place — either the old file remains, or the new fully-fsynced file does.

Encryption

AES-256-GCM under a key derived from the BIP39 seed via scrypt with a per-file random salt. Tampering with the ciphertext breaks the GCM tag — load() raises rather than returning attacker-shaped JSON.

Parameters:

path (Path)

Return type:

None

async send(client, to_address, photons, *, fee_rate=10000, change_address=None)[source]

Fetch UTXOs, build, sign, broadcast. Returns broadcast txid.

Raises ValidationError on bad inputs or insufficient funds, NetworkError on RPC failure.

Parameters:
Return type:

str

async send_max(client, to_address, *, fee_rate=10000)[source]

Sweep all UTXOs to to_address minus fee. Returns broadcast txid.

Parameters:
Return type:

str

zeroize()[source]

Scrub the seed and mark the wallet dead; it cannot derive or sign after.

Hardening #8/H1: the account xprv is NO LONGER stored long-lived — the _xprv property re-derives it transiently from the seed per operation — so the ONLY resident long-lived secret is this 64-byte seed, which lives in a SecretBytes and IS memset here. Setting _zeroed (matching SecretBytes._zeroed) makes the _xprv property fail closed (rather than silently re-deriving a garbage key from the now-zeroed seed). Any account-xprv copies that existed only during an in-flight derivation are short-lived locals (GC-eligible immediately, never held across the unlock window); their residency until the pages are reused is bounded by the agent’s best-effort process hygiene (mlock / PR_SET_DUMPABLE 0 / no core dumps), NOT a guaranteed erase — do not over-state it as “erased”.

Return type:

None

addresses: dict[str, AddressRecord]
class pyrxd.HtlcCovenant[source]

Bases: object

A built HTLC covenant: the funded SPK + the bindings a spend must satisfy.

variant

“ft” | “nft” | “rxd”.

Type:

str

funded_spk

The scriptPubKey of the covenant UTXO the maker locks the asset into.

Type:

bytes

prologue_len

Length of the compiled body (== len(funded_spk) for NFT/RXD; the offset of the FT epilogue weld for FT). The bare-0xbd guard pins to this.

Type:

int

taker_holder_script / maker_holder_script

The holder scripts output[0] of a claim (taker) / refund (maker) must equal; the covenant binds hash256 of each.

expected_taker_hash / expected_maker_hash

hash256(taker_holder_script) / hash256(maker_holder_script) — the values baked into the covenant.

genesis_ref

The 36-byte genesis outpoint ref (FT/NFT); b"" for RXD.

Type:

bytes

hashlock

The 32-byte H = SHA256(p).

Type:

bytes

refund_csv

The relative-timelock block count for the refund branch.

Type:

int

__init__(variant, funded_spk, prologue_len, taker_holder_script, maker_holder_script, expected_taker_hash, expected_maker_hash, genesis_ref, hashlock, refund_csv)
Parameters:
Return type:

None

variant: str
funded_spk: bytes
prologue_len: int
taker_holder_script: bytes
maker_holder_script: bytes
expected_taker_hash: bytes
expected_maker_hash: bytes
genesis_ref: bytes
hashlock: bytes
refund_csv: int
class pyrxd.MarginPolicy[source]

Bases: object

How the cross-chain timelock margin is computed and enforced.

margin

The required minimum t_btc - t_rxd, as a unit-tagged Timelock. If is_measured is False this is an ESTIMATE.

Type:

pyrxd.btc_wallet.taproot.Timelock

block_interval_s

Seconds-per-block used to normalise across units. For BTC the canonical target is 600s; supply a measured value for mainnet. Used both to normalise t_btc/t_rxd to a common unit and to convert the margin.

Type:

float

is_measured

True only when margin + block_interval_s were derived from real block data (both chains) + a stated reorg depth. Estimates are test-only.

Type:

bool

require_measured

“real-value” mode. When True, an estimated policy is refused at use time (fail-closed) — a mainnet swap must carry a measured margin.

Type:

bool

__init__(margin, block_interval_s, is_measured, require_measured=False, rxd_block_interval_s=300.0, btc_claim_reorg_depth=<factory>, rxd_claim_burial=<factory>, rxd_reorg_cost_per_block=None, value_at_risk_photons=None, burial_safety_factor=1.0, accept_flat_burial=False, eth_finalization_window_s=None, cross_clock_margin=None, max_covenant_confirm_wait_s=None)
Parameters:
  • margin (Timelock)

  • block_interval_s (float)

  • is_measured (bool)

  • require_measured (bool)

  • rxd_block_interval_s (float)

  • btc_claim_reorg_depth (Timelock)

  • rxd_claim_burial (Timelock)

  • rxd_reorg_cost_per_block (int | None)

  • value_at_risk_photons (int | None)

  • burial_safety_factor (float)

  • accept_flat_burial (bool)

  • eth_finalization_window_s (int | None)

  • cross_clock_margin (CrossClockMargin | None)

  • max_covenant_confirm_wait_s (int | None)

Return type:

None

accept_flat_burial: bool = False
burial_safety_factor: float = 1.0
cross_clock_margin: CrossClockMargin | None = None
classmethod estimated(*, block_interval_s=600.0, require_measured=False, accept_flat_burial=False)[source]

The ESTIMATED, test-only policy. Refuses to construct in real-value mode.

accept_flat_burial is the dust opt-out from the value-scaled-burial setup gate — set it for a deliberate dust run whose value is below the Radiant reorg cost.

Parameters:
  • block_interval_s (float)

  • require_measured (bool)

  • accept_flat_burial (bool)

Return type:

MarginPolicy

eth_finalization_window_s: int | None = None
max_covenant_confirm_wait_s: int | None = None
classmethod measured(*, margin, block_interval_s, btc_claim_reorg_depth=None, rxd_claim_burial=None, rxd_block_interval_s=None, rxd_reorg_cost_per_block=None, value_at_risk_photons=None, burial_safety_factor=1.0, accept_flat_burial=False)[source]

A measured policy for real-value mainnet swaps.

btc_claim_reorg_depth / rxd_claim_burial are the reorg gate’s measured inputs; if omitted they fall back to the ESTIMATED defaults (acceptable only because a measured policy still carries the estimated reorg depths — supply measured values for a real mainnet swap).

rxd_reorg_cost_per_block (measured, photons/block) + value_at_risk_photons (the assessed economic value) drive the VALUE-SCALED claim burial (red-team HIGH): supply both for a value-bearing Radiant swap, or set accept_flat_burial=True for a dust run — the coordinator refuses a value-bearing swap that leaves them unset.

Parameters:
  • margin (Timelock)

  • block_interval_s (float)

  • btc_claim_reorg_depth (Timelock | None)

  • rxd_claim_burial (Timelock | None)

  • rxd_block_interval_s (float | None)

  • rxd_reorg_cost_per_block (int | None)

  • value_at_risk_photons (int | None)

  • burial_safety_factor (float)

  • accept_flat_burial (bool)

Return type:

MarginPolicy

require_measured: bool = False
rxd_block_interval_s: float = 300.0
rxd_reorg_cost_per_block: int | None = None
value_at_risk_photons: int | None = None
margin: Timelock
block_interval_s: float
is_measured: bool
btc_claim_reorg_depth: Timelock
rxd_claim_burial: Timelock
class pyrxd.NegotiatedTerms[source]

Bases: object

Everything the two parties agree before any lock — chain-agnostic.

Carries the hashlock ``H`` only, never the preimage p (the maker holds p in memory as SecretBytes). ONE canonical hex wire form via to_dict()/from_dict() (JSON, never pickle).

Timelocks are unit-tagged Timelock (BIP68/112). The cross-chain ordering invariant t_btc - t_rxd >= margin is checked by the coordinator (see swap_coordinator.assert_timelock_margin), not here — but the raw ordering t_btc > t_rxd in the same unit is rejected at construction as a cheap fail-closed guard.

__init__(hashlock, btc_sats, radiant_amount, t_btc, t_rxd, asset_variant, genesis_ref, taker_dest_hash, maker_dest_hash, btc_claim_pubkey_xonly, btc_refund_pubkey_xonly, counter_chain='btc', value_amount=0, eth_timeout_unix_s=None, credential_ref=b'')
Parameters:
Return type:

None

counter_chain: str = 'btc'
credential_ref: bytes = b''
eth_timeout_unix_s: int | None = None
classmethod from_dict(d)[source]
Parameters:

d (dict)

Return type:

NegotiatedTerms

to_dict()[source]

Canonical JSON/hex wire form. NEVER contains the preimage p.

Return type:

dict

value_amount: int = 0
hashlock: bytes
btc_sats: int
radiant_amount: int
t_btc: Timelock
t_rxd: Timelock
asset_variant: str
genesis_ref: bytes
taker_dest_hash: bytes
maker_dest_hash: bytes
btc_claim_pubkey_xonly: bytes
btc_refund_pubkey_xonly: bytes
class pyrxd.PowChain[source]

Bases: object

One Bitcoin-family counter chain the Taproot-HTLC leg can run against.

network / testnet_network / regtest_network are the bech32 HRPs — the tag the leg, the locator, and the audit gates all key on. block_interval_s seeds MarginPolicy(block_interval_s=...).

__init__(name, network, testnet_network, regtest_network, block_interval_s)
Parameters:
  • name (str)

  • network (str)

  • testnet_network (str)

  • regtest_network (str)

  • block_interval_s (float)

Return type:

None

name: str
network: str
testnet_network: str
regtest_network: str
block_interval_s: float
class pyrxd.PrivateKey[source]

Bases: object

__init__(private_key=None, network=None)[source]

create private key from WIF (str), or int, or bytes, or CoinCurve private key random a new private key if None

Parameters:
  • private_key (str | int | bytes | PrivateKey | None)

  • network (Network | None)

address(compressed=None, network=None)[source]
Returns:

P2PKH address corresponding to this private key

Parameters:
  • compressed (bool | None)

  • network (Network | None)

Return type:

str

decrypt(message)[source]

Electrum ECIES (aka BIE1) decryption

Parameters:

message (bytes)

Return type:

bytes

decrypt_text(text)[source]

decrypt BIE1 encrypted, base64 encoded text

Parameters:

text (str)

Return type:

str

der()[source]
Return type:

bytes

derive_child(public_key, invoice_number)[source]

derive a child key with BRC-42 :param public_key: the public key of the other party :param invoice_number: the invoice number used to derive the child key :return: the derived child key

Parameters:
  • public_key (PublicKey)

  • invoice_number (str)

Return type:

PrivateKey

derive_shared_secret(key)[source]
Parameters:

key (PublicKey)

Return type:

bytes

encrypt(message)[source]

Electrum ECIES (aka BIE1) encryption

Parameters:

message (bytes)

Return type:

bytes

encrypt_text(text)[source]
Returns:

BIE1 encrypted text, base64 encoded

Parameters:

text (str)

Return type:

str

classmethod from_der(octets)[source]
Parameters:

octets (str | bytes)

Return type:

PrivateKey

classmethod from_hex(octets)[source]
Parameters:

octets (str | bytes)

Return type:

PrivateKey

classmethod from_pem(octets)[source]
Parameters:

octets (str | bytes)

Return type:

PrivateKey

hex()[source]
Return type:

str

int()[source]
Return type:

int

pem()[source]
Return type:

bytes

public_key()[source]
Return type:

PublicKey

serialize()[source]
Return type:

bytes

sign(message, hasher=<function double_sha256>, k=None)[source]
Returns:

ECDSA signature in bitcoin strict DER (low-s) format

Parameters:
Return type:

bytes

Low-s enforcement: coincurve’s sign() calls libsecp256k1 which normalises signatures to low-s (SECP256K1_EC_NORMALIZED) by default. For custom k, _sign_custom_k() explicitly enforces low-s.

Warning

Passing an explicit k bypasses RFC 6979 deterministic-nonce generation. ECDSA leaks the private key if the same k signs two different messages under the same key. Only supply k for an R-puzzle (see pyrxd.script.type.RPuzzle.unlock()) and only with a throwaway key that signs nothing else. Leave k as None for all normal signing — libsecp256k1’s deterministic nonce is the safe path.

sign_recoverable(message, hasher=<function double_sha256>)[source]
Returns:

serialized recoverable ECDSA signature (aka compact signature) in format r (32 bytes) + s (32 bytes) + recovery_id (1 byte)

Parameters:
Return type:

bytes

sign_text(text)[source]

sign arbitrary text with bitcoin private key :returns: (p2pkh_address, stringified_recoverable_ecdsa_signature) This function follows Bitcoin Signed Message Format. For BRC-77, use signed_message.py instead.

Parameters:

text (str)

Return type:

tuple[str, str]

verify(signature, message, hasher=<function double_sha256>)[source]

verify ECDSA signature in bitcoin strict DER (low-s) format

Parameters:
Return type:

bool

verify_recoverable(signature, message, hasher=<function double_sha256>)[source]

verify serialized recoverable ECDSA signature in format “r (32 bytes) + s (32 bytes) + recovery_id (1 byte)”

Parameters:
Return type:

bool

wif(compressed=None, network=None)[source]
Parameters:
  • compressed (bool | None)

  • network (Network | None)

Return type:

str

class pyrxd.RadiantCovenantLeg[source]

Bases: object

The concrete Radiant radiant_leg (HTLC covenant claim/refund).

Parameters:
  • network – Radiant network tag (regtest test chains bypass the audit gate).

  • maker_pkh (taker_pkh /) – The taker (claim) and maker (refund) Radiant holder pubkey-hashes. The covenant binds hash256(holder(pkh)); these must reproduce the terms’ taker_dest_hash/maker_dest_hash (asserted in expected_covenant_scriptpubkey()).

  • chain_io – A RadiantChainIO (broadcast + confirmations + UTXO value).

  • fee_source – A FeeUtxoSource supplying the fee input for each spend.

  • min_confirmations – Confirmations required before the funded covenant value is trusted.

  • audit_cleared – Explicit opt-in for a value-bearing network (see pyrxd.btc_wallet.htlc_leg.require_audit_cleared()).

__init__(*, network, taker_pkh, maker_pkh, chain_io, fee_source, min_confirmations=1, audit_cleared=False)[source]
Parameters:
Return type:

None

async claim_asset(record, preimage)[source]

Build + broadcast the TAKER’s claim spend (reveals p). Returns the txid.

Parameters:
  • record (SwapRecord)

  • preimage (bytes)

Return type:

str

async covenant_outpoint(terms)[source]

Locate the funded covenant UTXO txid:vout by scanning its SPK’s UTXO set.

The maker locks the asset into the covenant SPK (a pure function of the terms); the leg finds that single funded UTXO on-chain via ElectrumX. The carrier value is bound to terms.radiant_amount so a mis-funded covenant fails closed.

Parameters:

terms (NegotiatedTerms)

Return type:

str

async expected_covenant_scriptpubkey(terms)[source]

The covenant SPK the on-chain lock must equal (built from the terms).

Parameters:

terms (NegotiatedTerms)

Return type:

bytes

async refund_asset(record)[source]

Build + broadcast the MAKER’s CSV refund spend. Returns the txid.

Parameters:

record (SwapRecord)

Return type:

str

class pyrxd.RegtestNode[source]

Bases: object

A self-managed, isolated radiant-core regtest node (docker).

The node is identified by a fixed container name so that up / mine / fund / down invoked as separate processes all operate on the same chain. up is the only call that creates the container; the others attach to the running one and raise DevnetError if it is absent.

CONTAINER = 'pyrxd-devnet'
IMAGE = 'radiant-core:v3.1.1-amd64'
RPC_PASSWORD = 'pyrxd'
RPC_USER = 'pyrxd'
WALLET = 'devnet'
classmethod build_image(version='v3.1.1', *, no_cache=False)[source]

Build the regtest image from an OFFICIAL Radiant-Core release binary.

Wraps the published radiant-<version>-linux-x64 daemon (SHA-256-verified against the release checksum file) in a small ubuntu:22.04 image tagged radiant-core:<version>-amd64. Builds from the Dockerfile embedded in this module, so it works for a pip install pyrxd developer with no repo checkout as well as from a clone. Returns the built image tag.

This is the dev-facing replacement for the previously ad-hoc image that was built outside the repo; pyrxd regtest setup calls it.

Parameters:
Return type:

str

cli(*args, wallet=False)[source]

Run radiant-cli inside the container; parse JSON when possible.

Parameters:
Return type:

object

fund(address, amount_rxd, *, confirm=True)[source]

Faucet: send amount_rxd RXD to address from the dev wallet.

Mines one block to confirm the payment unless confirm is False. Returns the funding txid.

Parameters:
Return type:

str

info()[source]

Connection + chain summary for display.

Return type:

dict

is_running()[source]

True if the devnet container exists and is running.

Return type:

bool

mine(n=1, address=None)[source]

Mine n blocks to address (a fresh wallet address by default).

Returns the new chain height.

Parameters:
  • n (int)

  • address (str | None)

Return type:

int

new_address()[source]

A fresh address from the dev wallet.

Return type:

str

new_funded_key(amount_rxd=100.0)[source]

Generate a wallet key, fund it, and return its address + WIF.

The WIF is directly importable into pyrxd (PrivateKey(wif)), giving a developer a spendable, pre-funded regtest identity in one step.

Parameters:

amount_rxd (float)

Return type:

DevKey

start(*, fresh=False, initial_blocks=101)[source]

Start the regtest node, create the dev wallet, and mature a coinbase.

Idempotent unless fresh is set: if the container is already running it is left untouched (the chain state is preserved). fresh=True tears the existing container down first for a clean chain.

Parameters:
  • fresh (bool)

  • initial_blocks (int)

Return type:

None

stop()[source]

Remove the devnet container (no-op if absent). Wipes the chain.

Return type:

None

exception pyrxd.RxdSdkError[source]

Bases: Exception

Base class for every exception raised by pyrxd.

Applying redact to each positional arg on construction defends against accidental key-material leakage when callers pass user-supplied values straight into the exception.

__init__(*args)[source]
Parameters:

args (Any)

Return type:

None

class pyrxd.RxdWallet[source]

Bases: object

High-level wallet for plain RXD (photon) transfers on Radiant.

Parameters:
  • private_key – Wallet key. All UTXOs and the change output use the corresponding P2PKH address.

  • electrumx_url – ElectrumX WebSocket URL (wss://..). A single URL is accepted for ergonomic parity with ElectrumXClient([url]).

  • fee_rate – Miner fee in photons per byte. Defaults to 10_000 (the current mainnet relay minimum).

  • allow_insecure – Pass-through to ElectrumXClient. Only set for local dev.

__init__(private_key, electrumx_url, fee_rate=10000, *, allow_insecure=False)[source]
Parameters:
  • private_key (PrivateKey)

  • electrumx_url (str)

  • fee_rate (int)

  • allow_insecure (bool)

Return type:

None

property address: str

Return the P2PKH mainnet address of this wallet.

build_send_max_tx(utxos, to_address)[source]

Build and sign a tx sweeping all provided UTXOs to to_address.

No change output. Single output value = sum(utxos) - fee.

Parameters:
  • utxos (list[UtxoRecord])

  • to_address (str)

Return type:

Transaction

build_send_tx(utxos, to_address, photons)[source]

Build and sign a P2PKH transfer from utxos to to_address.

Pure offline operation: no network calls. Useful for unit tests and for callers who prefer to broadcast via their own client.

Rules

  • photons must be >= DUST_THRESHOLD (546).

  • UTXOs are greedily selected in descending order of value.

  • A change output back to self.address is added only if the remainder after paying the fee exceeds the dust threshold; otherwise the dust is burned as additional fee.

Parameters:
  • utxos (list[UtxoRecord])

  • to_address (str)

  • photons (int)

Return type:

Transaction

property fee_rate: int
async get_balance()[source]

Return (confirmed_photons, unconfirmed_photons) for this wallet.

Return type:

tuple[int, int]

async get_utxos()[source]

Return typed UtxoRecord list for this wallet.

Return type:

list[UtxoRecord]

property pkh: bytes

Return the raw 20-byte public-key hash.

async send(to_address, photons)[source]

Fetch UTXOs, build + sign + broadcast a P2PKH transfer.

Returns the transaction id on success. Raises ValidationError on bad inputs or insufficient funds, NetworkError on RPC failure.

Parameters:
  • to_address (str)

  • photons (int)

Return type:

str

async send_max(to_address)[source]

Sweep all confirmed UTXOs to to_address minus fee.

Returns the transaction id on success.

Parameters:

to_address (str)

Return type:

str

class pyrxd.SoulboundNftCovenant[source]

Bases: object

A built soulbound-NFT covenant.

funded_spk

The covenant scriptPubKey the NFT singleton is locked into. The ONLY non-burn spend is one whose output[0] equals this byte-for-byte.

Type:

bytes

genesis_ref

The 36-byte wire-format singleton ref bound by the covenant.

Type:

bytes

owner_pkh

The 20-byte hash160 of the immutable owner. Changing it yields a different funded_spk (which is precisely why transfer is impossible).

Type:

bytes

recur_target_spk

The scriptPubKey output[0] of a (non-burn) spend MUST equal. For a soulbound covenant this is identical to funded_spk — the self-clone.

__init__(funded_spk, genesis_ref, owner_pkh)
Parameters:
Return type:

None

property recur_target_spk: bytes
funded_spk: bytes
genesis_ref: bytes
owner_pkh: bytes
class pyrxd.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.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

class pyrxd.SwapCoordinator[source]

Bases: object

Drive the swap FSM for one live participant against injected chain legs.

Parameters:
  • record – The SwapRecord (durable state). The coordinator advances and returns NEW records (frozen dataclass); it does not mutate in place. Persist the returned record after every step (crash-recovery is from the record).

  • radiant_leg (btc_leg /) – Duck-typed chain legs. The BTC leg derives/funds/claims/refunds the P2TR HTLC and exposes the covenant-SPK derivation the gates need; the Radiant leg wraps the claim/refund builders. In tests these are fakes.

  • indexer – Duck-typed RefIndexer (verify_ref). Indexer-unavailable => fail-closed.

  • seen_store – Duck-typed SeenStore (reserve/has_seen) — H-freshness replay defence. A non-durable (in-process) store is refused on a value-bearing network unless config.accept_nondurable_seen is set.

  • configCoordinatorConfig (margin policy + maker-stall window).

  • persist – Optional async (SwapRecord) -> None durable-write hook. When supplied, the coordinator persists the intent record BEFORE an awaited broadcast and asyncio.shield()-s the post-broadcast persist, so a task cancelled between “BTC is locked on-chain” and “record advanced” cannot double-fund on retry (kieran-python HIGH). None disables durability (tests that do not exercise crash-atomicity); the in-memory record still advances.

__init__(*, record, counter_leg=None, btc_leg=None, radiant_leg, indexer, seen_store, config, persist=None, credential_resolver=None)[source]
Parameters:
  • config (CoordinatorConfig)

  • persist (Callable[[SwapRecord], Awaitable[None]] | None)

Return type:

None

property btc_leg

Transitional alias for counter_leg (the chain-neutral counter leg).

async maker_claims_btc(preimage)[source]

Maker spends the BTC claim leaf with p (revealing it), then zeroizes p.

Re-verifies sha256(p) == H before broadcasting (defends a swapped/garbled secret). The maker holds p only as SecretBytes; it is zeroized immediately after the claim is handed to the BTC leg.

p zeroization in finally runs on the cancel path too. If the awaited claim raises AFTER the tx hit the mempool, p is wiped from memory but is now public on-chain — recovery re-scrapes it from the chain, never memory.

Parameters:

preimage (SecretBytes)

Return type:

SwapRecord

async maker_verify_counter_funding(counter_contract_address)[source]

MAKER-side fail-closed gate (red-team CRITICAL fix): the maker MUST verify the TAKER-deployed counter-leg HTLC binds to the negotiated terms + the maker’s own payout config BEFORE the maker locks the asset (funds the RXD covenant). Returns on success (recording the verified locator on the record so maker_claims_btc() can claim it); RAISES on any mismatch — the maker MUST NOT lock the asset if this raises.

WHY THIS EXISTS: the runbook is TAKER-funds-counter-FIRST, MAKER-locks-asset-SECOND. For a BTC counter leg the funding target is a pure function of terms, so the coordinator’s derive==promised pre-fund gate + the funding reader already bind it. For an ETH counter leg there is NO pre-fund commitment — the contract does not exist until the taker deploys it — so EthHtlcContractLeg.verify_funded is the ONLY thing binding the taker’s contract to terms, and it previously ran ONLY inside the taker’s own fund(). Without this maker-side call a hostile taker deploys claimant=self (or underfunds / sets a bad timeout) and the honest maker locks the asset for nothing — a one-sided maker loss reachable in the intended two-party flow. The maker passes ONLY the contract ADDRESS (the one untrusted input from the taker); the leg builds the EXPECTED locator from the maker’s own config and verifies the chain matches it.

counter_contract_address is the address the taker advertises for its deployed HTLC.

Parameters:

counter_contract_address (str)

Return type:

SwapRecord

async maybe_refund_asset_on_maker_stall(*, now_block_height, asset_locked_at_height, maker_has_claimed_btc)[source]

If the maker is stalling near t_RXD - N, refund the asset proactively.

Drives BOTH_LOCKED -> MAKER_STALLS -> ASSET_REFUNDED_TAKER_ACTS. A no-op (returns the unchanged record) when the trigger has not fired yet. Async because the asset refund broadcasts a Radiant covenant spend.

RUNBOOK SCOPE (FSM finding #2, 2026-06-09 — VERIFIED on regtest): this refunds ONLY the RXD covenant, whose CSV refund pays the MAKER in BOTH directions (the maker owns the asset leg; p is not yet public) — it is NOT a “taker reclaims the covenant” action (an earlier note wrongly said the taker owns it; the covenant CLAIM pays the taker, the CSV REFUND pays the maker, same as eth_rxd_timelock.py).

This is a MAKER-side primitive (the maker recovering its own asset) and MUST NOT be wired into a TAKER recovery path on EITHER counter-chain. A taker driven to run it strands itself: it gifts the asset back to the maker AND destroys its only recourse (the claimable covenant) while its own counter-leg stays locked, after which the maker — still holding p — claims the counter-leg and takes both (proven by tests/test_xchain_swap_regtest_e2e.py:: TestMakerStallAssetOnlyRefundIsTakerLoss). The correct TAKER stall recovery on BOTH the BTC and ETH runbooks is mutual_refund() (refunds BOTH legs after both timeouts). The watchtower (gravity.watch.decide) routes neither counter-chain’s taker here.

Parameters:
  • now_block_height (int)

  • asset_locked_at_height (int)

  • maker_has_claimed_btc (bool)

Return type:

SwapRecord

async mutual_refund()[source]

Both legs refund after both timeouts elapse — the guaranteed-safe failure.

Valid from BOTH_LOCKED. The taker refunds BTC, the maker refunds the asset; neither suffers one-sided loss. Requires the full locator be retained. Async because both refunds broadcast on their chains.

Return type:

SwapRecord

async post_asset_lock_revalidate(observed_covenant_spk, *, now_unix_s=None)[source]

Re-check the on-chain covenant SPK == expected-from-terms+H.

Called when the maker locks the asset. The expected SPK is recomputed from the negotiated terms + H (the constructor params bind hashlock/refundCsv/ amount/dest-hashes/REF into the covenant bytecode). On match => BOTH_LOCKED. On mismatch => PARAMS_MISMATCH; the caller then refunds the BTC via the timelock leg (see taker_refund_btc()).

now_unix_s is the caller’s wall-clock at the moment the covenant lock is observed — REQUIRED for an ETH swap (the post-confirm cross-clock recheck against a stalled maker lock; audit re-verify HIGH), ignored for BTC. On an ETH timing failure this refuses to advance to BOTH_LOCKED (raises) so the taker refunds the counter leg.

Async because the Radiant leg reads chain state (expected-SPK derivation + covenant outpoint lookup) over the async indexer/node.

Parameters:
  • observed_covenant_spk (bytes)

  • now_unix_s (int | None)

Return type:

SwapRecord

async pre_btc_lock_check(terms, *, now_unix_s=None)[source]

Validate everything the taker can check BEFORE funding the counter leg (fail-closed).

Checks, in order (any failure => do NOT fund):
  1. REF authenticity via verify_ref_authenticity — the resolved reveal must bind to the ADVERTISED asset (genesis-outpoint==ref, gly marker, optional payload hash, ≥ min_ref_confirmations). Indexer unavailable / shallow genesis / wrong asset => fail-closed.

  2. H freshness — a read-only advisory probe of the seen-store (reused H => reject early). The authoritative atomic reserve happens later, in taker_funds_btc(), immediately before the broadcast.

  3. The cross-chain timelock ordering. BTC: the same-clock margin t_btc - t_rxd >= margin. ETH: the cross-clock gate that validates the ABSOLUTE eth_timeout_unix_s leaves room for the RELATIVE t_rxd window (needs now_unix_s; audit HIGH-1). The orphaned bridge is wired here.

  4. Maker-promised params match the locally re-derived BTC funding SPK (the on-chain re-validation happens later in post_asset_lock_revalidate()).

now_unix_s is the caller’s wall-clock (the now_rxd_height precedent: the coordinator takes clocks as params, never reads them) — REQUIRED for an ETH swap, ignored for BTC. Async because binding (1) awaits the async indexer adapter (a sync gate would leak a truthy un-awaited coroutine = fail-OPEN, T7 plan D2).

Parameters:
  • terms (NegotiatedTerms)

  • now_unix_s (int | None)

Return type:

PreBtcLockGate

async taker_claim_asset_from_vulnerable(maker_claim_tx_bytes)[source]

Best-effort asset claim from ASSET_VULNERABLE — an EXPLICIT policy decision.

Only valid from ASSET_VULNERABLE (reached when the reorg gate found the swap SQUEEZED). This is winner-take-all: the taker races to claim the asset before the maker’s t_rxd CSV refund lands, accepting the residual reorg risk that the gate flagged. It is a CONSCIOUS choice the caller makes after the gate refused the automatic SAFE claim — never invoked silently.

For an ETH counter leg maker_claim_tx_bytes carries the maker’s ETH claim tx hash; the scrape + provenance gate dispatch to the ETH path. The BTC body below is byte-for-byte unchanged.

Parameters:

maker_claim_tx_bytes (bytes)

Return type:

SwapRecord

async taker_funds_btc(terms, *, now_unix_s=None)[source]

Run the pre-lock gate, fund the counter-leg HTLC, record the locator, advance.

Refuses (raises) if the pre-lock gate fails — the taker NEVER funds against a failed gate. H is ATOMICALLY reserved in the seen-store PRE-broadcast (so a concurrent or repeat funder of the same H is refused before any value moves; TOCTOU-1), and the durable record carries the full counter-leg locator.

now_unix_s is the caller’s wall-clock — REQUIRED for an ETH swap (the cross-clock timelock-ordering gate, audit HIGH-1), ignored for BTC (byte-equivalent).

Atomicity (kieran-python HIGH): counter_leg.fund broadcasts on-chain, so a cancellation between the broadcast and the in-memory state advance would leave value locked but the record at NEGOTIATED → a retry double-funds. We persist an INTENT record (terms + derived funding SPK, enough to recover the address) BEFORE the awaited fund, and asyncio.shield() the post-broadcast persist of the funded record. fund itself must be idempotent (treat “already in mempool” as success) so a retry after an intent-only crash does not lock twice. Persistence is a no-op when no persist hook is injected.

Parameters:
  • terms (NegotiatedTerms)

  • now_unix_s (int | None)

Return type:

SwapRecord

async taker_refund_btc()[source]

Refund the BTC via the timelock leg, ending in ABORTED.

Valid from BTC_LOCKED (maker never locked, t_btc elapsed) or PARAMS_MISMATCH (maker locked the wrong covenant). The refund needs the FULL locator (Tapscript tree + control block) — recovered from the durable record. Async because the refund broadcasts the BTC timelock spend.

Return type:

SwapRecord

async taker_scrape_and_claim_asset(maker_claim_tx_bytes, *, now_rxd_height, asset_locked_at_height)[source]

Scrape p and claim the asset — gated on the maker’s BTC-claim finality.

Scraping is by sha256(candidate) == H over the witness pushes (never by offset); the coordinator RE-verifies sha256(p) == H first — a scraped value that does not open H is rejected.

Reorg gate (security-HIGH, plan 2026-05-26). The taker must NOT claim the asset off a not-yet-final BTC claim: a reorg of that claim after p is public reintroduces one-sided loss. Before firing the Radiant claim we read the maker’s BTC-claim confirmation depth and run the t_rxd-squeeze assessment (assess_claim_finality()). Three outcomes:

  • SAFE — claim now; advance to COMPLETED (the happy path).

  • WAIT — the BTC claim is too shallow but the window has room: do NOT claim, do NOT advance; the record stays SECRET_REVEALED and the caller retries later. (No state is stranded — the gate is before any advance.)

  • SQUEEZED — shallow claim AND the t_rxd window is closing: advance to ASSET_VULNERABLE (logged loudly) and STOP. The caller’s policy then decides a best-effort winner-take-all claim via taker_claim_asset_from_vulnerable() vs abandoning — never a silent claim off a shallow reveal.

now_rxd_height / asset_locked_at_height feed the squeeze (the Radiant clock; asset_locked_at_height is where the maker locked the covenant). scrape_secret is sync; the depth read + Radiant claim are awaited.

ETH counter leg. For an ETH↔RXD swap the maker’s claim is referenced by a tx HASH (carried in maker_claim_tx_bytes), not raw witness bytes: the flow dispatches to _taker_scrape_and_claim_eth(), which fetches calldata+logs, scrapes p, runs the ETH provenance gate (R6) and the finalized-checkpoint reorg gate. The BTC body below is unchanged and byte-for-byte identical to its proven form.

Parameters:
  • maker_claim_tx_bytes (bytes)

  • now_rxd_height (int)

  • asset_locked_at_height (int)

Return type:

SwapRecord

class pyrxd.SwapOffer[source]

Bases: object

A maker’s signed partial transaction plus everything a taker needs to verify it.

Transport-agnostic. partial_tx_hex holds the maker’s input (signed SINGLE|ANYONECANPAY) and output[0] (what the maker wants to receive). give_source_tx_hex is the full previous transaction that funds the maker’s input, so the taker can read the maker’s real given-asset value/script from the chain rather than trusting the declared terms — and confirm it hashes to the input’s outpoint.

__init__(partial_tx_hex, give_source_tx_hex, give_vout, terms)
Parameters:
Return type:

None

classmethod from_dict(d)[source]
Parameters:

d (dict)

Return type:

SwapOffer

to_dict()[source]
Return type:

dict

partial_tx_hex: str
give_source_tx_hex: str
give_vout: int
terms: SwapTerms
class pyrxd.SwapRecord[source]

Bases: object

The durable, crash-recoverable state of one in-flight swap.

Persisted from the FIRST lock onward (a crash that loses the BtcHtlcLocator strands the BTC — the refund needs the whole Tapscript tree + control block). Round-trips to/from JSON via hex; p is excluded by construction (the maker holds it in memory as SecretBytes, the taker re-scrapes it from chain).

Optional on-chain handles (filled in as locks land): * counterchain_locator — the funded counter-leg HTLC, a BtcHtlcLocator

(BTC swap) or EthHtlcLocator (ETH swap), after the counter-leg lock. The btc_locator property is a transitional BTC-only alias for it.

  • radiant_covenant_outpoint — “txid:vout” of the funded Radiant covenant (after BOTH_LOCKED).

  • radiant_covenant_spk_hex — the observed on-chain covenant scriptPubKey, used by the post-asset-lock revalidation gate.

__init__(state, terms, counterchain_locator=None, radiant_covenant_outpoint=None, radiant_covenant_spk_hex=None)
Parameters:
  • state (SwapState)

  • terms (NegotiatedTerms)

  • counterchain_locator (BtcHtlcLocator | EthHtlcLocator | None)

  • radiant_covenant_outpoint (str | None)

  • radiant_covenant_spk_hex (str | None)

Return type:

None

property btc_locator: BtcHtlcLocator | None

Transitional BTC-only alias for counterchain_locator — returns it iff it is a BtcHtlcLocator (else None). Lets BTC reader sites keep using .btc_locator until they migrate to the chain-neutral counterchain_locator.

counterchain_locator: BtcHtlcLocator | EthHtlcLocator | None = None
classmethod from_dict(d)[source]
Parameters:

d (dict)

Return type:

SwapRecord

radiant_covenant_outpoint: str | None = None
radiant_covenant_spk_hex: str | None = None
to_dict()[source]

JSON-serialisable form. The preimage p is NOT a field and is never written — serialising the record can never leak the secret to disk.

A BTC swap serialises in the v1 form (bare btc_locator, no schema_version), byte-for-byte identical to the pre-ETH schema; a swap whose counter-leg locator is an EthHtlcLocator serialises the v2 chain-tagged counterchain_locator + schema_version.

Return type:

dict

with_btc_lock(locator)[source]

Transitional alias for with_counter_lock() (BTC reader sites).

Parameters:

locator (BtcHtlcLocator)

Return type:

SwapRecord

with_counter_lock(locator)[source]

Attach the funded counter-leg locator (BTC or ETH).

Parameters:

locator (BtcHtlcLocator | EthHtlcLocator)

Return type:

SwapRecord

with_radiant_lock(outpoint, spk_hex)[source]
Parameters:
  • outpoint (str)

  • spk_hex (str)

Return type:

SwapRecord

with_state(state)[source]

Return a copy advanced to state (transition not re-validated here; the coordinator validates via advance() before persisting).

Parameters:

state (SwapState)

Return type:

SwapRecord

state: SwapState
terms: NegotiatedTerms
class pyrxd.SwapState[source]

Bases: Enum

The 13 states of the atomic-swap safety machine.

Terminal states (the diagram’s --> [*]) are enumerated in TERMINAL_STATES. Every non-terminal state has at least one defined exit (enforced by test_no_state_strands).

NEGOTIATED = 'negotiated'
BTC_LOCKED = 'btc_locked'
BOTH_LOCKED = 'both_locked'
SECRET_REVEALED = 'secret_revealed'
COMPLETED = 'completed'
MUTUAL_REFUND = 'mutual_refund'
PARAMS_MISMATCH = 'params_mismatch'
MAKER_STALLS = 'maker_stalls'
ASSET_VULNERABLE = 'asset_vulnerable'
ONE_SIDED_LOSS_TAKER = 'one_sided_loss_taker'
ABORTED = 'aborted'
ASSET_REFUNDED_TAKER_ACTS = 'asset_refunded_taker_acts'
class pyrxd.SwapTerms[source]

Bases: object

The trade as the maker states it: maker gives give, receives receive.

From the taker’s seat this reads in reverse — the taker receives give and pays receive. The terms are a human-readable cross-check; the maker’s signature on the partial tx is what actually enforces them (see pyrxd.swap.partial.accept_offer()).

__init__(give, receive)
Parameters:
Return type:

None

classmethod from_dict(d)[source]
Parameters:

d (dict)

Return type:

SwapTerms

to_dict()[source]
Return type:

dict

give: Asset
receive: Asset
class pyrxd.UtxoRecord[source]

Bases: object

A single unspent transaction output as returned by ElectrumX.

tx_hash

Transaction id in hex (little-endian / display order).

Type:

str

tx_pos

Output index within the transaction.

Type:

int

value

Output value in satoshis.

Type:

int

height

Block height at which the output was confirmed (0 = unconfirmed).

Type:

int

__init__(tx_hash, tx_pos, value, height)
Parameters:
Return type:

None

tx_hash: str
tx_pos: int
value: int
height: int
exception pyrxd.ValidationError[source]

Bases: RxdSdkError

Raised when input fails a trust-boundary validation check.

class pyrxd.Xprv[source]

Bases: Xkey

__init__(xprv)[source]
Parameters:

xprv (str | bytes)

address()[source]
Return type:

str

ckd(index)[source]
Parameters:

index (int | str | bytes)

Return type:

Xprv

classmethod from_seed(seed, network=Network.MAINNET)[source]

derive master extended private key from seed

Parameters:
private_key()[source]
Return type:

PrivateKey

public_key()[source]
Return type:

PublicKey

serialize()[source]

Return the base58check-encoded xprv string. Named explicitly to make audit grep easy.

Return type:

str

xpub()[source]
Return type:

Xpub

class pyrxd.Xpub[source]

Bases: Xkey

__init__(xpub)[source]
Parameters:

xpub (str | bytes)

address()[source]
Return type:

str

ckd(index)[source]
Parameters:

index (int | str | bytes)

Return type:

Xpub

classmethod from_xprv(xprv)[source]
Parameters:

xprv (str | bytes | Xprv)

Return type:

Xpub

public_key()[source]
Return type:

PublicKey

pyrxd.accept_offer(offer, *, funding, taker_receive_pkh, taker_change_pkh, fee)[source]

Complete and sign a maker’s offer, returning a broadcast-ready transaction.

Safety, by construction:

  • The maker’s given asset is read from offer.give_source_tx_hex (verified to hash to the maker input’s outpoint) — never from the declared terms — and reconciled against offer.terms.give.

  • The maker’s receive output (output[0]) is read from the partial tx and reconciled against offer.terms.receive.

  • The maker’s signature is re-verified both before and after the taker completes the transaction, so tampered terms are rejected.

  • Token conservation is enforced per FT ref; RXD change goes to the taker. The taker receives the maker’s given asset in output[1].

fee is the absolute fee in photons; the taker funds it.

Parameters:
Return type:

Transaction

pyrxd.bip32_derive_xkeys_from_xkey(xkey, index_start, index_end, path='m/', change=0)[source]

Derive a range of extended keys from Xprv and Xpub keys using BIP32 path structure.

Parameters:
  • xkey (Xprv | Xpub) – Parent extended key (Xprv or Xpub)

  • index_start (str | int) – Starting index for derivation

  • index_end (str | int) – Ending index for derivation (exclusive)

  • path (str) – Base derivation path (default: BIP32_DERIVATION_PATH)

  • change (str | int) – Change level (0 for receiving addresses, 1 for change addresses)

Returns:

List of derived extended keys

Return type:

List[Union[Xprv, Xpub]]

pyrxd.bip32_derive_xprv_from_mnemonic(mnemonic, lang='en', passphrase='', prefix='mnemonic', path='m/', network=Network.MAINNET)[source]

Derive the subtree root extended private key from mnemonic and path.

Parameters:
  • mnemonic (str)

  • lang (str)

  • passphrase (str)

  • prefix (str)

  • path (str)

  • network (Network)

Return type:

Xprv

pyrxd.bip44_derive_xprv_from_mnemonic(mnemonic, lang='en', passphrase='', prefix='mnemonic', path="m/44'/512'/0'", network=Network.MAINNET)[source]

Derives extended private key using BIP44 format- it is a subset of BIP32. Inherits from BIP32, only changing the default path value.

Parameters:
  • mnemonic (str)

  • lang (str)

  • passphrase (str)

  • prefix (str)

  • path (str)

  • network (Network)

Return type:

Xprv

pyrxd.build_htlc_covenant_ft(*, genesis_txid, genesis_vout, amount, taker_pkh, maker_pkh, hashlock, refund_csv)[source]

Build the FT-variant HTLC covenant (genesis ref bound via the FT epilogue weld).

Parameters:
Return type:

HtlcCovenant

pyrxd.build_htlc_covenant_nft(*, genesis_txid, genesis_vout, nft_carrier_value, taker_pkh, maker_pkh, hashlock, refund_csv)[source]

Build the NFT-variant HTLC covenant (singleton d8<ref> inside the body).

Parameters:
Return type:

HtlcCovenant

pyrxd.build_htlc_covenant_rxd(*, amount, taker_pkh, maker_pkh, hashlock, refund_csv)[source]

Build the RXD-variant HTLC covenant (native RXD: NO genesis ref, NO ref ops).

Parameters:
Return type:

HtlcCovenant

pyrxd.build_soulbound_nft_covenant(genesis_ref, owner_pkh)[source]

Build a consensus-enforced soulbound NFT covenant SPK.

Parameters:
  • genesis_ref (GlyphRef) – The Glyph singleton’s genesis ref (becomes the d8 singleton binding).

  • owner_pkh (bytes) – 20-byte hash160 of the immutable owner. Baked into the locking script so that any “transfer” (clone with a different owner) is a different script and fails the recur OP_EQUALVERIFY.

Returns:

With both static guards (exactly-one-ref, no-nonminimal-push) run fail-closed at build time.

Return type:

SoulboundNftCovenant

pyrxd.ckd(xkey, path)[source]

ckd = “Child Key Derivation” derive an extended key according to path like “m/44’/0’/1’/0/10” (absolute) or “./0/10” (relative)

Parameters:
Return type:

Xprv | Xpub

pyrxd.create_offer(*, give_source_tx, give_vout, maker_key, receive, maker_receive_pkh)[source]

Build a maker’s signed partial-swap offer.

The maker offers to spend give_source_tx.outputs[give_vout] (the given asset, owned by maker_key) in exchange for receive paid to maker_receive_pkh in output[0]. The given input is signed SINGLE|ANYONECANPAY so any taker can complete the swap.

The whole given UTXO is spent (its full value flows to the taker); pre-split the UTXO beforehand to sell a partial amount.

Parameters:
Return type:

SwapOffer

pyrxd.generate_secret()[source]

Generate a fresh CSPRNG preimage p and its hashlock H = SHA256(p).

Returns (p_as_SecretBytes, H_bytes). p is wrapped in the intentionally-unpicklable SecretBytes so it can never be serialised to disk. Only H is safe to put in NegotiatedTerms/SwapRecord.

Return type:

tuple[SecretBytes, bytes]

pyrxd.mnemonic_from_entropy(entropy=None, lang='en')[source]
Parameters:
Return type:

str

pyrxd.script_hash_for_address(address)[source]

Return the ElectrumX script_hash for a P2PKH address.

ElectrumX indexes addresses by sha256(locking_script) with the bytes reversed (little-endian display order). This public helper lets callers derive the script hash without constructing a full client.

Parameters:

address (str) – Base58Check-encoded P2PKH address.

Returns:

The 32-byte script hash suitable for ElectrumX RPC calls.

Return type:

Hex32

pyrxd.seed_from_mnemonic(mnemonic, lang='en', passphrase='', prefix='mnemonic')[source]
Parameters:
Return type:

bytes

async pyrxd.verify_ref_authenticity(indexer, genesis_ref, *, asset_variant, min_confirmations, expected_payload_hash=None)[source]

Hard pre-payment gate: confirm the covenant’s REF is a real minted asset.

await this BEFORE the taker pays any BTC for an FT/NFT swap. Plain-RXD swaps carry no ref and are skipped. Enforces the five bindings (a)-(e) documented at module level and fails closed on EVERY uncertain outcome: indexer unreachable/error, None (unknown token), a missing/invalid field, genesis-outpoint ≠ ref, absent gly marker, payload mismatch, or a genesis shallower than min_confirmations.

Parameters:
  • indexer (RefAuthenticityIndexer) – a trusted RefAuthenticityIndexer. A lying or attacker-controlled indexer defeats this gate — the taker must use an indexer they trust (the audit-gated track adds SPV/multi-source cross-checking; a single indexer is a SPOF, see T7 plan D3).

  • genesis_ref (bytes) – the 36-byte genesis outpoint ref baked into the covenant. This IS the advertised asset’s identity (binding d).

  • asset_variant (str) – “rxd” | “ft” | “nft”. Only ft/nft carry a ref to verify.

  • min_confirmations (int) – required confirmations on the genesis tx (binding e). Must be a non-negative int.

  • expected_payload_hash (bytes | None) – if the taker agreed to a specific payload, the reveal’s payload hash MUST match it (binding c). None skips this single binding (the others still apply).

Raises:

ValidationError – if the ref is not provably the advertised authentic asset. The caller MUST NOT pay the counter-leg (BTC or ETH) when this raises.

Return type:

None

pyrxd.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