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, a Glyph fungible token, or a Glyph NFT singleton.

amount is in photons. For an FT this is also the token-unit count (Radiant convention: 1 photon = 1 FT unit). For an NFT it is the singleton’s CARRIER value (the photons riding on the one UTXO that holds the singleton ref — the NFT itself is the ref, indivisible). ref is the token’s genesis/commit outpoint (the permanent identity) and is required for — and only for — kind in ("ft", "nft").

__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', 'nft']
amount: int
exception pyrxd.BroadcastEchoMismatch[source]

Bases: RxdSdkError

The server’s txid did not match the transaction we signed.

Deliberately NOT a ValidationError. Those are raised before anything is SENT — transfer_nft’s is raised after signing but before broadcast — whereas this one can only happen after the broadcast, when the transaction may well have relayed. A caller with except ValidationError: retry would re-broadcast a transfer that already moved tokens.

Carries local_txid so the caller can check the chain for what was actually sent.

__init__(local_txid, echoed)[source]
Parameters:
Return type:

None

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

release_unspent(fee_input)[source]

Credit back the cap charge for a dispensed input that was never broadcast.

The cap authorises spend, but next_fee_input() had to charge it at dispense — and the caller dispenses before it can know whether the spend will build. A build that refuses (the fee is below the node’s deadline-aware relay floor) puts nothing on-chain and pays no fee, yet left the charge standing: repeated refusals ate the budget the covering input needed, and the pool exhausted with a funded, unspendable UTXO still in it while the asset ran out its deadline (audit B3).

Returns True when the charge was credited, False when this input was already credited (idempotent — a second credit would fabricate budget). Raises ValidationError for anything this source has not dispensed.

The cursor is deliberately not rewound. Dispense-once is the property that stops one UTXO ever backing two transactions, and it must survive the un-charge: the released input is retired, and the next dispense moves on to the next one — which is also what stops a small head-of-line input from re-refusing forever while a covering input sits behind it.

Only the caller can know whether it broadcast, so this is a report, not an inference. Call it exactly on the paths where the dispensed input provably never reached a node.

Parameters:

fee_input (FeeInput)

Return type:

bool

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.

exception pyrxd.CekCommitmentMismatch[source]

Bases: ValidationError

The CEK offered for publication is not the one this token committed to.

A ValidationError, so it lands with everything else raised BEFORE a broadcast. Publishing the wrong key is worse than publishing nothing: the reveal is spent, the payload stays unreadable forever, and there is no second reveal to correct it.

class pyrxd.ChunkedCiphertext[source]

Bases: object

Chunked ciphertext + the plaintext SHA-256 used as the per-chunk AAD prefix.

plaintext_hash MUST be the SHA-256 of the full original plaintext (not any individual chunk). Decrypting without this hash will fail tag verification on every chunk.

__init__(chunks, plaintext_hash)
Parameters:
  • chunks (list[EncryptedChunk])

  • plaintext_hash (bytes)

Return type:

None

chunks: list[EncryptedChunk]
plaintext_hash: bytes
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, fund_lock=None, accept_estimated_eth_margins=False, min_credential_confirmations=6, role=None)
Parameters:
  • margin_policy (MarginPolicy)

  • maker_stall_safety_window_blocks (int)

  • min_ref_confirmations (int)

  • accept_nondurable_seen (bool)

  • fund_lock (Any)

  • accept_estimated_eth_margins (bool)

  • min_credential_confirmations (int)

  • role (SwapRole | None)

Return type:

None

accept_estimated_eth_margins: bool = False
accept_nondurable_seen: bool = False
fund_lock: Any = None
maker_stall_safety_window_blocks: int = 6
min_credential_confirmations: int = 6
min_ref_confirmations: int = 6
role: SwapRole | None = None
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, *, on_deploy=None, resume_from=None, push_nonce=None, on_push_nonce=None)[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).

on_deploy is an optional async (address: str) -> None the leg MUST await as soon as it knows an on-chain location that may hold value but is not yet a returned locator — and, where funding takes more than one transaction, strictly BEFORE the value moves. It exists because chains differ in when that location becomes knowable: a BTC P2TR funding address is derived from terms before anything is broadcast, so the caller can persist it up front, while an ETH CREATE address depends on the deployer’s nonce and does not exist until the deploy receipt returns. A leg whose address IS pre-derivable may ignore this.

Legs that ignore it must still ACCEPT it. The caller passes it to close a real fund-loss gap — value on chain that no durable record references — and a leg that rejects the argument turns that into a crash at funding time.

resume_from is the same handle coming back: a previously reported location whose funding did not complete. When set, the leg MUST NOT create a second HTLC — it completes the existing one, re-reading what already landed there so a lost receipt cannot double-fund, and it MUST verify that location really carries this swap’s terms before sending anything to it. A leg whose funding address is derived from terms is idempotent by construction and may ignore this.

push_nonce / on_push_nonce pin the value-moving transaction to a specific sender nonce and report that nonce so the caller can make it durable BEFORE the broadcast. A leg whose chain gives exclusive, replace-not-add semantics per nonce gets idempotent funding from this: a retry at the same pin delivers the value exactly once, no matter how many processes or hosts attempt it. A leg on a chain without that property may ignore both.

Parameters:
  • terms (Any)

  • on_deploy (Any)

  • resume_from (Any)

  • push_nonce (Any)

  • on_push_nonce (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.CredentialResolver[source]

Bases: Protocol

Indexer surface to resolve a credential ref to its CURRENT live UTXO.

Mirrors pyrxd.gravity.ref_authenticity.RefAuthenticityIndexer but resolves the credential’s current (unspent) locking script — what governs transferability now — rather than the genesis. resolve_credential is async and MUST raise or return None (both fail-closed) when it cannot reach a definitive answer; never return an optimistic stand-in.

__init__(*args, **kwargs)
async resolve_credential(credential_ref)[source]

Resolve credential_ref to its current UTXO, or None if unknown/spent.

Parameters:

credential_ref (bytes)

Return type:

ResolvedCredential | None

class pyrxd.EncryptedChunk[source]

Bases: object

One chunk of a chunked-aead-v1 ciphertext.

ciphertext is the bytes returned by the AEAD (includes the 16-byte Poly1305 tag); nonce is the 24-byte XChaCha20 nonce used for this chunk. Photonic emits both fields on the wire — pyrxd preserves them identically for round-trip compatibility.

__init__(ciphertext, nonce)
Parameters:
Return type:

None

ciphertext: bytes
nonce: bytes
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, *, on_deploy=None, resume_from=None, push_nonce=None, on_push_nonce=None)[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.

That stash is MEMORY-ONLY and dies with the process, which is why on_deploy now exists alongside it: the leg awaits it with the deployed address as soon as the deploy confirms (and, for the token leg, strictly before the tokens are pushed), so the coordinator can write the address to the durable record first. This is the coordinator-record-level recovery previously deferred as a Phase-4 item.

Return type:

EthHtlcLocator

locked_amount(locator)[source]

The funded amount the coordinator binds to terms.value_amount.

Wei for a native-ETH leg; the TOKEN’s base units for an Erc20HtlcLocator (USDC has 6 decimals, not 18). The comparison stays correct across both because the same locator field supplies this number and terms.value_amount was negotiated in the same unit — the unit is carried by the locator TYPE and terms.token_address, not by this method.

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 reveals p.

ORDERING — this ran the other way before HZ-1 (#392) and the docstring did not follow. The MAKER locks RXD FIRST; taker_funds_btc refuses until pre_btc_lock_check step 5 has read the covenant off the Radiant chain. So by the time this runs the asset is ALREADY committed, and what this gate protects is the REVEAL, not the lock: a hostile taker who deploys claimant=self, underfunds, or sets a bad timeout is caught here, before p goes public. Refusing leaves the maker at BTC_LOCKED with its CSV refund open — a lost swap, not a lost asset. (Do not restore the old wording: a reviewer reading it filed a MEDIUM against a gate placement that the protocol had already moved.) 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, is_testnet=False)
Parameters:
  • name (str)

  • chain_id (int)

  • network (str)

  • finalization_window_s (int)

  • is_testnet (bool)

Return type:

None

is_testnet: bool = False

Whether this chain’s coins are FAUCET money. Stated per entry, never inferred.

network cannot answer this. It feeds the audit gate, whose cleared set holds Bitcoin-family tags only — so every EVM chain here, testnets included, reads as “not audit-cleared”. That is correct for what that gate does (nothing here is audit-cleared) and useless for deciding whether real value is at stake. Reading it as the latter forced measured margins and a multi-endpoint quorum onto a Base Sepolia rehearsal: a guard refusing honest work, caught by the runner’s own wiring tests.

Not derived from the name either. “ends in -sepolia” is true of every testnet in this registry today and is a naming convention, not a property; the next testnet that breaks it would be silently promoted to real-value.

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

Bases: object

Submit the claim via a Flashbots-style private-tx RPC (eth_sendPrivateRawTransaction).

relay_url is the private endpoint (e.g. https://rpc.flashbots.net/fast). auth_key is a PrivateKeyMaterial used ONLY to sign the X-Flashbots-Signature request header — it is NOT the tx signing key and need not hold funds (Flashbots uses it as a stable searcher identity / reputation key). The tx itself is already signed by the leg’s key before it reaches here.

Fail-closed (NetworkError) on any transport/relay error: the caller (the coordinator’s maker-claim step) must NOT treat a failed private submit as a successful reveal.

__init__(*, relay_url, auth_key, timeout_s=10.0)[source]
Parameters:
Return type:

None

async submit_raw(raw_tx)[source]

Submit raw_tx privately; return its tx hash.

NOTE (red-team MEDIUM): a successful submit is NOT inclusion — a relay can ACK and drop the tx. The caller MUST drive maker-side confirmation (wait_receipt / finality) before treating the reveal as durable; do not infer ‘p is on-chain’ from this returning. We DO verify the relay-returned hash equals keccak256(raw_tx) locally (catches a buggy/wrong-hash relay, matching the public send_raw guarantee that the node computes the hash from the bytes).

Parameters:

raw_tx (bytes)

Return type:

str

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 into a collection

[NFT] + in

prepare_container_child_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_airdrop_tx(params)[source]

Build one signed transaction paying FT units to many recipients.

Thin delegator to FtUtxoSet.build_airdrop_tx(), exactly as build_ft_transfer_tx() delegates to build_transfer_tx — the selection, conservation and two-pass fee logic live on the UTXO set so both API surfaces share one implementation rather than two that can disagree about how many units exist.

Parameters:

params (FtAirdropParams) – FtAirdropParams — see dataclass docstring.

Returns:

FtAirdropResult.

Raises:
  • ValidationError – bad recipient list, or the conservation backstop.

  • ValueError – fee rate below Radiant’s relay floor, or the selected inputs’ RXD cannot cover dust + royalty + fee.

Return type:

FtAirdropResult

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. That method is itself a single-recipient FtUtxoSet.build_airdrop_tx(), so the recipient output’s value is params.amount and nothing else.

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, sub-floor fee rate, funding too small).

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.

A CONTAINER is transferred by this method too — its locking script is the 63-byte NFT singleton, and its collection membership lives in the envelope, so a transfer cannot drop it. Nothing extra to do.

Parameters:

params (TransferParams) – TransferParams — see dataclass docstring

Returns:

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

Raises:
  • ValidationError – nft_script is not a valid 63-byte NFT script

  • ValueError – nft_utxo_value - fee below pyrxd’s uneconomic-output floor pyrxd.constants.DUST_THRESHOLD_PHOTONS — a pyrxd policy, not a Radiant relay limit

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_authority_gated_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, authority_ref, authority_script)[source]

Prepare scripts for minting an item gated on an issuer’s authority.

Build the reveal with two inputs — the commit outpoint and the authority token’s UTXO — and two token outputs:

output

script

0

item_script

1

authority_script

(plus change). The authority output is re-emitted VERBATIM, so it neither moves nor changes hands — and, more to the point, is not destroyed. Spending a singleton without re-creating it burns it irrecoverably.

authority_script is the authority UTXO’s CURRENT locking script, not a PKH, for two reasons. Rebuilding it from a PKH with build_nft_locking_script STRIPS anything the authority itself carried — an authority that is itself authority-gated came back un-gated, silently, in the transaction that was supposed to leave it untouched. And a Hex20 here sat next to owner_pkh, so transposing the two irreversibly gifted the issuer’s authority to the mint recipient in a transaction consensus accepts. A script cannot be confused with a PKH.

Consensus enforces the gate: OP_REQUIREINPUTREF in the item’s script is subset-checked against this transaction’s inputs, so the mint fails outright without the authority. Measured, along with what the gate does NOT bind, in tests/test_authority_regtest_e2e.py — read build_authority_gated_nft_script() before treating “gated” as a durable property of the minted item.

Raises:

ValidationError – the envelope is unparseable or does not include GlyphProtocol.NFT.

Parameters:
Return type:

AuthorityGatedRevealScripts

static prepare_burn_proof(token_ref, *, amount=None, burn_reason=None)[source]

The OP_RETURN output that records a deliberate burn.

Add it to the transaction that spends the token, with value 0, and do NOT re-create the token in any output. See build_burn_proof_script() for what the proof does and does not establish — it is an operator claim, and verify_burn() is careful about which parts of it a reader may rely on.

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_child_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, container_ref, *, container_script)[source]

Prepare scripts for revealing a token into a container.

The child is an ordinary NFT. What makes its membership checkable is the transaction shape: the reveal spends the container’s own NFT UTXO and re-creates it unchanged, so the container ref appears among the reveal’s output-script refs. Photonic’s indexer only honours an in entry that it can find there (filterRels, packages/app/src/electrum/worker/NFT.ts) — a claimed in ref with no matching output ref is dropped, which is what stops anyone declaring their token part of someone else’s collection.

Build the reveal with two inputs — the commit outpoint and the container NFT UTXO — and two token outputs:

output

script

0

nft_script

1

container_script

(plus any change). container_script is the container’s OWN current locking script and is re-emitted VERBATIM, so output 1 is byte-identical to the UTXO being spent by construction rather than by assumption. It previously took a Hex20 and rebuilt the container with build_nft_locking_script, which silently returned an authority-gated, mutable or soulbound container as a plain 63-byte NFT — same ref, covenant gone — in the one transaction whose purpose is to leave the container untouched. Re-gating needs the issuer, so the loss was not recoverable by the holder.

To re-own the container deliberately, pass the script you want it to have; that keeps the intent visible at the call site instead of hiding it behind a PKH argument that sat next to owner_pkh and could be transposed with it.

cbor_bytes MUST already declare the membership — encode the child’s metadata with container_refs=[container_ref]. This method cross-checks it rather than editing the payload, because the payload hash is already committed to on chain by the commit output.

Raises:

ValidationError – the envelope is unparseable, does not include GlyphProtocol.NFT, or its in list does not contain container_ref.

Parameters:
Return type:

ContainerChildRevealScripts

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

Prepare scripts for a CONTAINER (collection) reveal.

A container’s locking script is the plain 63-byte NFT singleton of build_nft_locking_script(). Container-ness is carried by the 7 marker in the envelope’s p field, exactly as in Photonic Wallet (packages/lib/src/script.ts has one nftScript and no container variant). That is what makes a container a first-class token: every NFT classifier, the scanner, and build_nft_transfer_tx() handle it unchanged.

Membership points child → parent and lives in the child’s envelope, in the in field (container_refs). Use prepare_container_child_reveal() to mint a member.

Protocol field must include GlyphProtocol.CONTAINER (7).

Parameters:
Return type:

ContainerRevealScripts

The child_ref prefix (removed in 0.15.0)

pyrxd 0.9.0–0.14.0 prefixed the NFT body with OP_PUSHINPUTREF <child_ref> when child_ref was given. That 100-byte script was never a working token, and both defects were confirmed against a Radiant Core v3.1.1 regtest node (tests/test_container_regtest_e2e.py):

  • The output could not be spent. OP_PUSHINPUTREF leaves the ref on the stack and nothing drops it, so the P2PKH tail hashed the ref and OP_EQUALVERIFY failed for every possible scriptSig. Any photons placed on it were unrecoverable.

  • Creating one destroyed the child NFT. OP_PUSHINPUTREFSINGLETON also registers its ref as a disallowed sibling (CScript::GetPushRefs), so the child could not be re-created alongside the container — and a singleton consumed into a 0xd0 push never re-enters inputSingletonRefSet, so it can never be minted again.

Even with the missing OP_DROP repaired, a script-level link to a live NFT is impossible on Radiant for the second reason, and a repaired link would still be droppable by the holder at any transfer. Membership is therefore metadata, here as in Photonic.

prepare_dat_commit(params)[source]

Prepare a DAT (data-storage) commit — a glyph that mints no token.

Same two-transaction shape as prepare_commit(), but the commit carries no OP_REFTYPE_OUTPUT obligation, so the reveal creates no NFT and no FT. What survives is the payload in the reveal’s scriptSig.

Protocol must include GlyphProtocol.DAT (3). Build the reveal with prepare_dat_reveal() — a DAT commit pops an extra "dat" marker and the ordinary reveal scriptSig is one push short of satisfying it.

Parameters:

params (CommitParams)

Return type:

CommitResult

prepare_dat_reveal(cbor_bytes, *, delegate_ref=None)[source]

Prepare a DAT reveal’s scriptSig suffix.

There is no locking_script: a DAT reveal mints nothing, so the caller’s outputs are whatever they want to keep the value on (ordinary P2PKH change). RevealScripts.locking_script is returned empty to say so rather than handing back a token script that would be wrong.

Parameters:
Return type:

RevealScripts

prepare_delegate_setup(owner_pkh, authorised_refs, *, parent_scripts, base_ref=None, token_count=0)[source]

Prepare the one-time delegate setup that authorises in/by claims.

This is the alternative to prepare_container_child_reveal() for a minting service, and the only write path pyrxd has for by at all. Where the container-child reveal makes every mint spend and re-create the parent — permanent custody in the minting wallet, and one serialised UTXO every mint contends on — a delegate spends the parents once.

Two transactions, because the second needs the first’s outpoint:

  1. Call with authorised_refs only. Spend the container and/or author tokens, with outputs = base_script plus every script in :attr:`~DelegateSetupScripts.parent_scripts`, which re-create the parents unchanged. Consensus refuses the base output unless those refs really were among the inputs (OP_REQUIREINPUTREF is subset-checked), which is what makes every later claim authorised rather than merely asserted.

    Omitting the parent outputs BURNS the container and author tokens. OP_REQUIREINPUTREF requires a ref as an input and does not carry it forward, so a base transaction that does not re-create the parents destroys them — permanently, since a consumed singleton can never be re-minted. Only once the parents are back in outputs is it true that they can go to cold storage and never be spent again.

  2. Call again with base_ref (that output’s outpoint) and a token_count. Spend the base, paying to each of token_scripts. Pre-mint as many as you expect to need — N tokens serve N concurrent mints with no lock on a shared UTXO, which is the operational point.

Warning

A delegate token is not one mint. It is an unlimited mint pass. An earlier version of this text said “each token authorises one mint”; that was wrong, and MEASURED wrong on a node (test_ONE_delegate_token_can_mint_MANY_more). The 56-byte prefix is a covenant on the REVEAL. The commit that spends a delegate token is an ordinary transaction under no covenant, and the token’s script is OP_PUSHINPUTREF <base> — so spending one puts the base ref in the input ref set, and consensus lets one input ref back arbitrarily many output copies. One token was spent into three on regtest.

So a token handed to a third party lets them mint into the collection without limit until the base is retired. Treat the tokens as bearer credentials for the collection, not as counted vouchers, and keep them in the minting service rather than distributing them.

Then each mint passes base_ref as CommitParams.delegate_ref, spends one token in the commit, and emits RevealScripts.delegate_burn_script in the reveal.

What this does NOT prove: that the parent’s owner approved this particular mint. It proves the mint held a token from a base that held the parents. Anyone holding a delegate token can make the claim — that is the mechanism working as designed, and why RelationshipBasis reports DELEGATED separately from DIRECT rather than flattening the two.

Parameters:
  • parent_scripts (Sequence[bytes]) –

    the parents’ OWN current locking scripts, in the same order as authorised_refs, re-created verbatim.

    This used to be a single parent_owner_pkh and the outputs were rebuilt with build_nft_locking_script(). That is the exact hazard prepare_authority_gated_reveal() documents forty lines from here and refuses: rebuilding from a PKH STRIPS whatever the parent itself carried. A container or author that is itself authority-gated (101 bytes), mutable, or held by a soulbound covenant came back as a plain 63-byte NFT — ref preserved, covenant gone — in the one transaction whose stated purpose is to leave the parents untouched before they return to cold storage.

    Taking the scripts verbatim also settles the question the old parameter existed for. A parent keeps paying whoever it already paid, so a hot minting service cannot silently move a cold-held singleton to the hot key, and two parents held by two different keys are not consolidated. Both were argued for at length in prose; now neither is expressible.

    Each script is cross-checked with script_carries_ref() against the ref it is paired with, so a mismatched or reordered list is refused rather than silently re-creating the wrong parent.

  • owner_pkh (Hex20)

  • authorised_refs (Sequence[GlyphRef])

  • base_ref (GlyphRef | None)

  • token_count (int)

Raises:

ValidationErrorauthorised_refs is empty, or token_count is given without base_ref (or vice versa with no tokens to build).

Return type:

DelegateSetupScripts

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. The permanent token ref is the commit outpoint (commit_txid:commit_vout), which this method embeds into the reveal’s locking script — not the reveal’s own outpoint.

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), carrying ref = commit_txid:commit_vout

  • contract_script: 174-byte mutable contract UTXO (holds state), carrying mutable_ref = commit_txid:(commit_vout + 1)

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]).

The reveal needs TWO inputs

input

outpoint

0

commit_txid:commit_vout — the commit (reveal scriptSig)

1

commit_txid:(commit_vout + 1) — a plain seed output

The commit transaction must therefore carry a second, ordinary output at ``commit_vout + 1`` (Photonic funds it with 1 photon; Radiant has no dust rule). Spending it is what puts mutable_ref into the transaction’s input singleton-ref set, which is the only thing that lets an output push it.

Why the two refs cannot be the same one

pyrxd 0.9.0-0.15.0 used ref for both scripts. A reveal built as documented above was rejected by consensus every time — confirmed against a Radiant Core v3.1.1 regtest node (tests/test_mut_wave_regtest_e2e.py), reject reason bad-txns-inputs-outputs-invalid-transaction-reference-operations. Two independent chain rules forbid it:

  • OP_PUSHINPUTREFSINGLETON files its ref into foundDisallowedSiblingRefs as well as the push-ref set (CScript::GetPushRefs), and validateTransactionReferenceOperations rejects a transaction where two outputs claim the same one. Both the NFT script and the mutable contract lead with 0xd8, so they can never carry the same ref.

  • The contract’s own body derives the token ref from its ref by subtracting one from the vout (OP_DUP 20 OP_SPLIT OP_BIN2NUM OP_1SUB OP_4 OP_NUM2BIN OP_CAT). mutable_ref.vout == ref.vout + 1 is therefore not a convention — the covenant computes it. With equal refs the contract would look for commit_vout - 1 and match nothing, so even a repaired sibling rule would leave the contract unspendable.

This matches Photonic Wallet (packages/lib/src/mint.ts: Outpoint.fromUTXO(mint.utxo.txid, mint.utxo.vout + 1)).

Note

Spending the contract later (the mod / sl operations of build_mutable_scriptsig()) additionally requires the token output to be re-created in Photonic’s nftAuthScript shape — an OP_REQUIREINPUTREF <mutable_ref> <sha256(contract scriptSig)> OP_2DROP state prefix ahead of the singleton. pyrxd has no builder for that shape yet; the working transaction is spelled out in tests/test_mut_wave_regtest_e2e.py.

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, allow_confusable=False)[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, at most 255 characters, and must not impersonate Latin text — see pyrxd.glyph.wave.validate_wave_text(), which is the single definition of that rule and is applied here and in build_wave_metadata(). This method is the funnel every WAVE registration crosses, whatever built its CBOR, so the check belongs here rather than only in the metadata helper a caller may not have used. Pass allow_confusable=True to register a look-alike deliberately. 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)].

The reveal shape is MUT’s, including its two-input requirement: the commit outpoint plus a seed outpoint at commit_vout + 1 that gives the mutable contract its own singleton ref. See prepare_mutable_reveal() — a WAVE registration built without the seed input is rejected by consensus, as every one built through 0.15.0 was.

Parameters:
Return type:

MutableRevealScripts

class pyrxd.GlyphClient[source]

Bases: object

Mint and transfer Glyph tokens over one ElectrumX client and one wallet.

Usage:

client = GlyphClient(electrumx, wallet, store=JsonFilePendingStore("~/.pyrxd/pending"))
result = await client.mint_nft(metadata)
receipt = await client.transfer_ft(ref, 250, recipient_pkh)

Transfer-only callers can skip the store:

client = GlyphClient(electrumx, wallet)
receipt = await client.transfer_ft(ref, 250, recipient_pkh)
Parameters:
  • client – an ElectrumX-style client — await broadcast(raw_tx: bytes) -> txid, await get_transaction(txid), await get_utxos(script_hash).

  • wallet – an HdWallet, or anything exposing await collect_spendable(client), privkey_for_address(address) and addresses.

  • store – where a PendingMint lives between commit and reveal. Required for minting, unused by transfers.

  • fee_rate – photons per byte, applied to every build. A rate above the overpay ceiling is always refused here. A sub-floor rate is refused here too when store is given — mints judge it in the constructor — and otherwise left to each build path, since the floor is a property of the chain.

  • allow_below_relay_floor – accept a sub-floor fee_rate, for chains whose floor really is lower — relay_floor_photons_per_byte() is a fixed mainnet constant, and a regtest node runs at a tenth of it. Required at construction when store is given, since minting judges the rate in the constructor rather than per build. Since #458 this covers transfers as well as mints, on both the FT and NFT paths: it is threaded to the FT builder and to build_nft_transfer together, because doing it for one alone would reintroduce the FT/NFT asymmetry that caused a release blocker on this surface.

  • min_confirmations – depth required on a mint’s commit before its reveal.

  • confirmation_timeout_s – how long a reveal waits for the commit.

  • poll_interval_s – seconds between confirmation polls, forwarded to GlyphMinter. Lower it for a chain that mines on demand; the default suits minutes-apart blocks.

__init__(client, wallet, *, store=None, fee_rate=10000, allow_below_relay_floor=False, min_confirmations=1, confirmation_timeout_s=1800.0, poll_interval_s=10.0)[source]
Parameters:
Return type:

None

async airdrop_ft(ref, recipients, *, allow_overpay=False)[source]

Distribute ref to many recipients in one transaction, and broadcast.

The orchestration behind this lived only in the CLI until now, so a library caller had to reimplement it or drive the builder directly.

Like its transfer siblings the returned txid is derived from the bytes that were signed, not from the node’s echo: a lying or buggy server could drop the transaction and echo a well-formed — even real and already-confirmed — txid, leaving a caller polling something unrelated to their tokens.

Raises:
  • BroadcastEchoMismatch – the node echoed a txid other than the one the signed bytes hash to. Deliberately not a ValidationError: those are raised before anything is sent, and a caller retrying on one would re-broadcast a distribution that may already have moved tokens.

  • InsufficientFundsError – not enough of the token, or no plain-RXD UTXO to pay the fee. Raised before anything is signed or sent.

Parameters:
Return type:

AirdropReceipt

async broadcast_timelock_reveal(build)[source]

Broadcast a reveal that was already built and shown to someone.

Split out from reveal_timelock() so a caller that displayed a build can send those bytes. The CLI confirms a reveal by printing the key it is about to publish; calling reveal_timelock after that prompt would build a second transaction and broadcast it instead — a confirmation showing one artifact and sending another, which is worse than no confirmation because it looks like one.

The build already carries a checked plan; there is no way to construct a TimelockRevealBuild that has not been through plan_timelock_reveal().

Parameters:

build (TimelockRevealBuild)

Return type:

TimelockRevealReceipt

async build_ft_airdrop(ref, recipients, *, allow_overpay=False)[source]

Build and sign a multi-recipient FT airdrop without broadcasting it.

One transaction, not N transfers: sequential transfers chain, each spending the previous one’s change, so a failure partway leaves the set half-delivered and the token’s ref alone cannot tell you which half. Output order follows recipients.

Parameters:
Return type:

FtAirdropBuild

async build_ft_transfer(ref, amount, to_pkh, *, allow_overpay=False)[source]

Build and sign an FT transfer without broadcasting it.

For callers that want to show the user what is about to be spent — which is exactly what the CLI does — or to inspect the transaction first.

Parameters:
Return type:

FtTransferBuild

async build_nft_transfer(ref, to_pkh, *, allow_overpay=False)[source]

Build and sign an NFT transfer without broadcasting it.

The singleton keeps its own value; the fee comes from a separate plain-RXD input. See pyrxd.glyph.transfer.build_nft_transfer() for why this does not go through GlyphBuilder.build_nft_transfer_tx().

Parameters:
Return type:

NftTransferBuild

build_timelock_mint(*, name, content_type, plaintext, params, cek=None, recipients=(), locator=None)[source]

Encrypt and seal content without minting it. See build_timelock_mint().

Synchronous and network-free: this is pure construction, and it is exposed on the client so a caller can inspect the envelope — and take a copy of the CEK — before committing anything to a chain. mint_timelocked_nft() is this plus the mint.

Parameters:
  • name (str)

  • content_type (str)

  • plaintext (bytes)

  • params (TimelockParams)

  • cek (bytes | None)

  • recipients (Sequence[TimelockRecipient])

  • locator (str | None)

Return type:

TimelockMintBuild

async build_timelock_reveal(metadata, *, token_ref, cek, hint='', allow_early=False, allow_overpay=False)[source]

Build and sign a reveal without broadcasting it — the dry run.

For showing an operator exactly what would become public before it does, which is what pyrxd glyph timelock-reveal --dry-run does with it. The plan inside the returned build has already passed the commitment check and the unlock gate; there is no way to obtain one of these that has not.

Parameters:
Return type:

TimelockRevealBuild

async commit_ft(metadata, *, supply, treasury_pkh=None)[source]

Phase 1 of an FT deploy. See GlyphMinter.commit_ft().

Added alongside reveal_ft() rather than after it: exposing only the reveal would let a caller FINISH a two-phase FT deploy through this facade that they could not START through it — half of a pair is the asymmetry the facade exists to remove, not a smaller version of the fix.

Parameters:
Return type:

PendingMint

async commit_nft(metadata, *, owner_pkh=None)[source]

Phase 1 of an NFT mint. See GlyphMinter.commit_nft().

Parameters:
Return type:

PendingMint

async deploy_ft(metadata, *, supply, treasury_pkh=None)[source]

Deploy a fungible token with a full premine. See GlyphMinter.deploy_ft().

Spelled out rather than *args, **kwargs: this is a published SDK, and the erased signature was the only one of the five facade methods that gave a caller no completion, no type checking, and a TypeError from inside the minter instead of at the call site.

Parameters:
Return type:

MintResult

async mint_nft(metadata, *, owner_pkh=None)[source]

Commit and reveal an NFT singleton. See GlyphMinter.mint_nft().

Parameters:
Return type:

MintResult

async mint_timelocked_nft(*, name, content_type, plaintext, params, persist=None, cek=None, recipients=(), locator=None, owner_pkh=None)[source]

Seal plaintext behind a timelock and mint the NFT that commits to its key.

The mint itself is mint_nft() — the same two-phase commit/reveal, the same store, the same fee rules. What this adds is the envelope: the content is encrypted with chunked-aead-v1, the key’s SHA-256 goes on chain as crypto.timelock, and the key comes back to the caller.

The CEK and ciphertext are not recoverable from the chain. The mint carries a commitment to the key and a hash of the plaintext, nothing more; a mint cannot be re-run and there is no path from sha256(cek) back to cek.

KEY CUSTODY HAS TO PRECEDE THE COMMIT, so this method makes you say how. Supply either persist — called with the TimelockMintBuild after the envelope is built and before a single byte is broadcast — or cek, a key you already hold. With neither, this raises before touching the network.

The refusal is not pedantry about defaults. Generating the key inside a call that then blocks on confirmation put the only copy of it in a local variable for as long as a Radiant block takes: a NetworkError, a ConfirmationTimeoutError, a cancellation or a kill in that window and the receipt is never constructed, while the pending store holds a resumable commit whose CBOR commits to sha256(cek). The documented recovery — reveal_nft() on that pending mint — then succeeds, and mints a token nobody can ever open. The advice this docstring used to give instead (“persist both halves of the receipt before doing anything else with it”) is advice a caller cannot act on: the receipt does not exist until after the window has closed. pyrxd glyph timelock-mint never had this problem because it writes its files before broadcasting; the hook is that ordering, for the SDK.

persist may be sync or async, and anything it raises propagates with nothing broadcast. The build it receives carries cek, ciphertext, cek_hash, stub and metadata — and metadata is worth saving too: a commit that confirms while its reveal does not is spendable only by a reveal pushing byte-identical CBOR, which a build with recipients cannot reproduce.

See build_timelock_mint() for the rest of the arguments. owner_pkh behaves as it does on mint_nft(), defaulting to the funding key’s own hash.

Raises:

ValidationError – neither persist nor cek was given, no store was configured (minting needs one), or the parameters were refused. Raised before anything is broadcast.

Parameters:
Return type:

TimelockMintReceipt

property minter: GlyphMinter

The underlying GlyphMinter.

Built on first use so a transfer-only client never needs a store.

Raises:

ValidationError – if this client was constructed without a store.

async plan_timelock_reveal(metadata, *, token_ref, cek, hint='', allow_early=False)[source]

Check a reveal against the chain’s own clock, and return what it would publish.

The clock is read here rather than taken from the caller, which is the point of the method existing: plan_timelock_reveal() cannot judge a lock it is not given a time for, and a caller passing its own number is a caller who can pass the wrong one.

  • mode="block" — the tip height from get_tip_height().

  • mode="time" — the timestamp in the tip block’s header, not this process’s wall clock. A local clock can be wrong by any amount and nothing would say so, and the header timestamp is at least the unit the lock was written in. It is not exact — a block’s timestamp may run ahead of real time under consensus rules — so this is a gate against the obvious mistake, not a substitute for the operator knowing what they are publishing.

The clock is the SERVER’S, and this SDK does not authenticate it. Neither read is verified: get_tip_height checks only that a non-negative integer came back and get_block_header only that 80 bytes did. Nothing checks the proof of work behind that height, links the header to one already known, or asks a second endpoint — and pyrxd’s default endpoints are third-party public servers. So an endpoint that overstates the tip obtains a permanent early reveal from a gate that reports itself satisfied, and one that merely lags refuses an honest holder past unlock_at. Calling this “the chain’s clock” would be the more reassuring sentence and it would not be true: it is one server’s claim about the chain.

What the reveal path does with that is show it. The returned plan carries judged_at — the reading actually compared against — and pyrxd glyph timelock-reveal prints it beside opens at in the pre-broadcast summary, so the operator can disagree with a number that would otherwise never have been on screen. “Summary”, not “prompt”: under --yes there is no question, and for a while that meant no summary either, which made this whole sentence true only of the interactive run. It is now printed on every path that can broadcast — stdout in human mode, stderr under --json/--quiet — and recorded again on the receipt afterwards. An SDK caller who needs more than that should pass a clock they trust to plan_timelock_reveal() directly.

Everything the plan is checked for happens in the underlying function; see its docstring. This adds only the clock.

Parameters:
Return type:

TimelockRevealPlan

async reveal_ft(pending, *, fee_rate=None, allow_below_relay_floor=None, allow_overpay=False)[source]

Phase 2 of an FT deploy. See GlyphMinter.reveal_ft().

The FT counterpart of reveal_nft(), and it exists for the sharper half of the reason that one does. A two-phase FT deploy could not be FINISHED through this facade at all: a caller who committed had to reach past it into .minter. The commit output is a hashlock with no owner-only spend path, so the phase a caller most needs to resume was the one not exposed.

allow_below_relay_floor must stay None-defaulted, not False — the same trap documented at length on reveal_nft(). None means “inherit the constructor”; False means “the caller re-asserted the floor for this reveal”. A False default here would forward a deliberate override on every ordinary call, so a client built with allow_below_relay_floor=True would commit and then refuse to reveal, stranding the commit and everything funded into it.

Parameters:
Return type:

MintResult

async reveal_nft(pending, *, fee_rate=None, allow_below_relay_floor=None, allow_overpay=False)[source]

Phase 2 of an NFT mint. See GlyphMinter.reveal_nft().

The three overrides are forwarded because without them the minter’s escape hatch is unreachable from this facade: a commit whose stored fee rate now sits below a risen relay floor cannot be revealed at all, and re-pricing upward is only possible when the commit holds enough to pay it.

allow_below_relay_floor must stay None-defaulted, not False. The minter reads None as “the caller said nothing, inherit the constructor” and False as “the caller re-asserted the floor for this reveal”. Defaulting to False here forwarded a deliberate override on every ordinary call, so a client built with allow_below_relay_floor=True committed and then refused to reveal — stranding the commit, which is a hashlock with no owner-only spend path. That is exactly the failure the constructor flag exists to prevent, reintroduced one layer above it by a default that looked harmless.

Parameters:
Return type:

MintResult

async reveal_timelock(metadata, *, token_ref, cek, hint='', allow_early=False, allow_overpay=False)[source]

Publish the CEK on chain, and broadcast.

Irreversible. After this relays, anyone holding the ciphertext can decrypt it, forever. There is no unreveal and no second reveal.

Two mistakes are refused before anything is sent, both by plan_timelock_reveal(): a CEK that is not the one this token committed to, and a reveal before unlock_at without allow_early. Both are ValidationError subclasses, so they land with everything else raised pre-broadcast.

Raises:
  • CekCommitmentMismatchsha256(cek) is not the token’s commitment. Publishing it would spend the reveal and leave the payload unreadable for good.

  • TimelockNotExpired – the lock has not expired and allow_early was not set.

  • BroadcastEchoMismatch – the node echoed a txid other than the one the signed bytes hash to. Deliberately not a ValidationError — a caller retrying on one would re-publish a key that may already be public.

  • InsufficientFundsError – no plain-RXD UTXO large enough to fund the reveal. Raised before anything is signed.

Parameters:
Return type:

TimelockRevealReceipt

async transfer_ft(ref, amount, to_pkh, *, allow_overpay=False)[source]

Send amount units of ref to to_pkh, and broadcast.

The recipient output is sized from amount; the fee is paid from a separate plain-RXD input, because an FT output’s value is its unit count and taking the fee from the token would short the recipient.

Raises:
  • InsufficientFundsError – not enough of the token, or no plain-RXD UTXO to pay the fee. Raised before anything is signed or sent.

  • ValidationError – inputs span multiple keys, the builder refused the parameters, or change would have been paid to the miner.

Parameters:
Return type:

TransferReceipt

async transfer_nft(ref, to_pkh, *, allow_overpay=False)[source]

Send the NFT singleton ref to to_pkh, and broadcast.

The singleton’s value crosses unchanged and the fee is paid from plain RXD. There is no amount: a singleton is indivisible, and the value on an NFT output is dust rather than a quantity.

Raises:
  • InsufficientFundsError – this wallet does not hold the NFT, or has no plain-RXD UTXO large enough to pay the fee. Raised before anything is signed or sent.

  • ValidationError – the signed transaction does not pay for its own size.

Parameters:
Return type:

NftTransferReceipt

class pyrxd.GlyphInspector[source]

Bases: object

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

classify_glyph_scriptsig(scriptsig)[source]

What kind of gly envelope, if any, does this scriptSig carry?

THE POINT OF THIS METHOD IS THE THIRD ANSWER. extract_reveal_metadata() returns None both when there is no glyph here and when there is one it could not parse, and those are opposite facts: the first means “nothing to report”, the second means “something is being published that I cannot read”. Collapsing them makes the blind case look like the empty one, and the blind case is the more confident-sounding of the two.

That is not hypothetical. Measured on the mainnet chain for custodian-gate-x7f3.rxd, three of four transactions carry the marker and the reveal parser decoded exactly one — so “this WAVE name was never updated” and “I cannot read WAVE updates” produced identical output, and the wrong one is the reassuring one.

Returns:

None when no push equals the gly marker — genuinely not a glyph scriptSig. Otherwise a GlyphEnvelope whose kind is "payload" (a full token payload), "update" (a partial update — mutated fields only), or "unreadable" (the marker is there and neither reader accepted what followed).

Parameters:

scriptsig (bytes)

Return type:

GlyphEnvelope | None

extract_reveal_cbor(scriptsig)[source]

The RAW envelope bytes this scriptSig carries, or None.

Selected by exactly the same walk and marker rule as _parse_reveal_scriptsig(), deliberately: a caller hashing these bytes against a commit’s payload_hash must hash the push that extract_reveal_metadata() actually decoded, or the check answers about a different payload than the one on screen. Sharing the selection is what makes the two agree; two walkers here have already drifted once.

Returns the bytes undecoded, because the check is over the wire form.

Parameters:

scriptsig (bytes)

Return type:

bytes | None

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, dMint contract outputs, and the dead pre-0.15.0 container-with-child-ref shape. 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.

A CONTAINER reports as "nft": its locking script is the NFT singleton, so no script-level classifier can distinguish one, here or in any other implementation. Container-ness is an envelope property — read it from the reveal metadata (GlyphProtocol.CONTAINER in protocol, surfaced by GlyphMetadata.is_container and pyrxd.glyph.wave.classify_glyph_metadata()). GlyphScanner does this join for you.

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='', timelock=None, encrypted_main=None, crypto=None, source_cbor=None, container_refs=(), author_refs=())
Parameters:
Return type:

None

author_refs: tuple[GlyphRef, ...] = ()
commit_outpoint: str = ''
container_refs: tuple[GlyphRef, ...] = ()
created: str = ''
creator: GlyphCreator | None = None
crypto: CryptoMetadata | None = None
decimals: int = 0
description: str = ''
dmint_params: DmintCborPayload | None = None
encrypted_main: EncryptionMetadata | 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 = ''
property is_container: bool

True when this envelope marks the token itself as a CONTAINER.

Either declaration counts. GlyphProtocol.CONTAINER (7) is the spec’d form and NO mainnet token uses it — all four containers on Radiant mainnet declare type: “container” on an ordinary NFT/MUT protocol set, so a protocol-only test was False for every real container (#578).

Verified on chain: the “BTC” container (reveal 57c4d660…dfb1) decodes to p = (2,) with type = ‘container’.

Both are DECLARATIONS — type is operator CBOR and nothing on chain enforces it, exactly as nothing enforces the protocol array.

loc: str = ''
loc_hash: str = ''
main: GlyphMedia | None = None
name: str = ''
policy: GlyphPolicy | None = None
rights: GlyphRights | None = None
royalty: GlyphRoyalty | None = None
source_cbor: bytes | None = None
ticker: str = ''
timelock: TimelockSpec | None = None
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, object]

Glyph attrs carry non-strings in the wild (Photonic authority tokens use a boolean revocable and a permissions list). Values are scalars or lists of scalars; _decode_attr_value() flattens anything deeper. Consumers expecting text should str() what they read, as WaveAttrs.from_dict does.

Type:

dict[str, object], not dict[str, str]

class pyrxd.GlyphMinter[source]

Bases: object

Two-phase Glyph minting over an ElectrumX client and an HD wallet.

commit_* broadcasts the commit and returns a persisted PendingMint; reveal_* waits for the commit and broadcasts the reveal. The pair is separable on purpose — a caller can exit between them and resume from the store — and mint_nft() / deploy_ft() compose each pair for callers who do not need to.

Usage:

store = JsonFilePendingStore("~/.pyrxd/pending-mints")
minter = GlyphMinter(client, wallet, store)
result = await minter.mint_nft(metadata)

or, resumably:

pending = await minter.commit_nft(metadata)   # persisted, then broadcast
...                                           # crash, reboot, next week
pending = store.load(commit_txid)
result = await minter.reveal_nft(pending)
Parameters:
  • client – an ElectrumX-style client — await broadcast(hex) -> txid and await get_transaction_verbose(txid) -> dict. Duck-typed, matching wait_for_confirmation().

  • wallet – an HdWallet. Exactly two methods are used — await collect_spendable(client) -> [(utxo, address, privkey)] to fund the commit, and privkey_for_address(address) to re-derive the reveal’s signing key. Duck-typed like client, and deliberately NOT hidden behind a coin-source Protocol: HdWallet is the only real implementer, and a one-implementer Protocol is indirection with nothing on the other side. The two-method surface is small enough to stand in for directly — see examples/regtest_quickstart.py, which drives the minter from a single regtest key.

  • store – where the PendingMint is kept between phases. Required.

  • fee_rate – photons per byte for both transactions.

  • allow_below_relay_floor – accept a fee_rate under the relay floor, for chains whose floor really is lower — a regtest node runs at a tenth of mainnet’s. Applies to the reveal as well, so a minter built this way can finish what it starts. Leave it False against mainnet.

  • min_confirmations – depth required on the commit before the reveal is built. See DEFAULT_MINT_CONFIRMATIONS — the default is convention.

  • confirmation_timeout_s – how long reveal_* waits before raising ConfirmationTimeoutError. The PendingMint survives a timeout, so the reveal can be retried.

  • poll_interval_s – seconds between confirmation polls; must be > 0. The default suits a chain whose blocks are minutes apart; a regtest node that mines on demand wants a much smaller value, and until this was exposed there was no way to ask for one — the reveal always slept the full 10s default, so confirmation_timeout_s below that could not take effect until a whole interval had elapsed. Zero is refused: the poll happens before the sleep, so it would busy-loop the server for the whole timeout.

__init__(client, wallet, store, *, fee_rate=10000, allow_below_relay_floor=False, min_confirmations=1, confirmation_timeout_s=1800.0, poll_interval_s=10.0)[source]
Parameters:
Return type:

None

async commit_ft(metadata, *, supply, treasury_pkh=None)[source]

Broadcast the commit for a fungible-token deploy with a full premine.

Parameters:
  • metadata (GlyphMetadata) – must carry FT. DMINT is refused — a dMint deploy emits parallel contract UTXOs and belongs to prepare_dmint_deploy().

  • supply (int) – units issued, all placed on the reveal’s token output. Radiant convention is 1 photon = 1 FT unit, so this is also that output’s value, and it must clear pyrxd’s 546-unit decimals-mistake guard (a pyrxd policy, not a chain limit — Radiant’s output floor is 1 photon).

  • treasury_pkh (Hex20 | bytes | None) – who receives the premine. Defaults to the funding key’s PKH.

Raises:
Return type:

PendingMint

async commit_nft(metadata, *, owner_pkh=None)[source]

Broadcast the commit for an NFT singleton mint.

The PendingMint is persisted and read back before the broadcast.

Parameters:
  • metadata (GlyphMetadata) – must carry NFT and none of the tags whose reveal has a different shape (MUT, WAVE, DMINT) — those are refused rather than committed to a reveal this module cannot build. CONTAINER is supported: a collection’s reveal is this same single-output NFT shape.

  • owner_pkh (Hex20 | bytes | None) – recipient. Defaults to the funding key’s own PKH.

Raises:
  • ValidationError – on an unsupported protocol mix.

  • InsufficientFundsError – if no single UTXO can fund the mint, or if the measured reveal could not pay its fee out of the commit. Both are raised before anything is broadcast.

Return type:

PendingMint

async deploy_ft(metadata, *, supply, treasury_pkh=None)[source]

commit_ft() then reveal_ft(). See mint_nft().

Parameters:
Return type:

MintResult

async mint_nft(metadata, *, owner_pkh=None)[source]

commit_nft() then reveal_nft(), waiting for the commit in between.

Convenient, not different: the commit is persisted before broadcast here too, because the store is a constructor dependency rather than a per-call keyword. That is the reason it sits on the constructor — a single-call helper cannot forget to pass it.

Note this blocks for as long as the commit takes to confirm. If that is a problem, drive the two phases yourself.

Parameters:
Return type:

MintResult

async reveal_ft(pending, *, fee_rate=None, allow_below_relay_floor=None, allow_overpay=False)[source]

Wait for the commit, then broadcast the FT deploy reveal.

Mirrors reveal_nft(); the reveal’s token output carries the whole premined supply recorded in PendingMint.carrier_value.

Parameters:
Return type:

MintResult

async reveal_nft(pending, *, fee_rate=None, allow_below_relay_floor=None, allow_overpay=False)[source]

Wait for the commit, then broadcast the NFT reveal.

The stored record is re-validated against the commit script before anything is built — see _assert_payload_still_matches(). On success the record is deleted from the store; on failure it is kept so the reveal can be retried.

Parameters:
Return type:

MintResult

property store: PendingStore

The configured PendingStore — resume through it after a crash.

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 fetch_metadata(ref)[source]

The mint envelope for ref, read off the chain. None if it cannot be found.

Public because a caller may want a token’s own metadata without wanting its holder’s whole inventory — pyrxd glyph timelock-reveal needs exactly this, and needs it from the CHAIN rather than from the operator: a CEK checked against a commitment the operator supplied proves only that they typed two matching things.

Walks commit-output history to find the reveal that carried the envelope; see the module docstring for why ref.txid alone is not enough.

Parameters:

ref (GlyphRef)

Return type:

GlyphMetadata | 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 when the reveal transaction cannot be located or carries no readable envelope (see _resolve_reveal_metadata()) — including transfer outputs whose commit-output history is unavailable.

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 resolutions 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. Metadata is resolved once per distinct ref, so an FT split across many UTXOs costs one resolution, not N.

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.

  • fee_policy – Min-relay rate every transaction this session builds is sized and checked against. Defaults to DEFAULT_RADIANT_DEADLINE_FEE_POLICY (mainnet). Set this on regtest, whose node advertises a tenth of the mainnet floor — without it the high-level API has no way to reach the escape hatch the builders already accept.

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=12_000_000,
        fee_sats=2_500_000,  # ~250-byte funding tx at the 10_000 photons/byte floor
    )
    active = await session.create_offer(params)
    claim_txid = await session.wait_for_claim(active, timeout_seconds=3600)
    if claim_txid is None:
        # fee omitted: sized from the cancel tx's own measured bytes.
        cancel_txid = await session.cancel_offer(active, maker_address=maker_addr)
__init__(rxd_client, maker_priv, btc_source=None, poll_interval_seconds=30, fee_policy=None)[source]
Parameters:
Return type:

None

async cancel_offer(offer, fee_sats=None, maker_address='', fee_policy=None)[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 | None) – Miner fee in photons for the cancel tx. None (the default) sizes it from the assembled transaction’s own bytes at the relay floor — the only correct default, because the cancel scriptSig carries the whole MakerOffer redeem script and its size therefore varies per offer. This parameter previously defaulted to 1000, ~2,840x under the floor for a 285-byte cancel, which made the documented cancel_offer(active) flow raise on first use and left the Maker with no revocation path.

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

  • fee_policy (DeadlineFeePolicy | None) – Per-call override of the session’s policy. Set on regtest, which advertises a tenth of the mainnet relay floor.

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, *, clock=<built-in function monotonic>)[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, as WALL-CLOCK seconds. Returns None on timeout.

  • clock (Callable[[], float]) – Monotonic-ish time source. Injectable so the timeout branch is reachable in a test without sleeping — the same shape pyrxd.network.confirm uses, and for the same reason: a fake sleep does not advance a real clock.

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,
        # Photons, at the 10,000/byte mainnet floor: size it from the tx you
        # actually build. These are worked examples, not constants to copy.
        fee_sats=3_000_000,  # ~300-byte claim
        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=30_000_000,  # finalize carries the SPV proof: ~10x the claim
    )
__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, fee_policy=None)[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. Must clear the relay floor for the assembled transaction’s real size — at the mainnet floor of 10,000 photons/byte a ~300-byte claim needs ~3,000,000 photons, not the 1000 this docstring used to show.

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

  • fee_policy (DeadlineFeePolicy | None) – Per-call override of TradeConfig.fee_policy.

Return type:

ClaimResult

async finalize(btc_txid, offer, claimed_txid, claimed_vout, claimed_photons, taker_address, fee_sats, btc_tx_height=None, fee_policy=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. The finalize tx is by far the largest in this module — it pushes the whole BTC transaction, N block headers and the Merkle branch into one scriptSig — so its relay floor is an order of magnitude above the claim’s. A fee that was ample for a claim is nowhere near enough here.

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

  • fee_policy (DeadlineFeePolicy | None) – Per-call override of TradeConfig.fee_policy.

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. Bound the same way the config field is (>= 1), which it was not: TradeConfig has refused min_btc_confirmations < 1 since it was written, but the per-call override went straight to the source. Measured against a source holding the tx in the mempool only, min_confirmations=0 returned confirmed=True, confirmations=0 on the FIRST poll and -5 returned confirmations=-5 — a “confirmed” verdict for a transaction with no depth at all, handed to the caller who is about to release the other leg. Zero is not a weaker policy here, it is no policy: the depth this waits for MUST equal the covenant’s header-depth N.

Returns:

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

Return type:

ConfirmationStatus

Raises:
  • NetworkError – If polling exceeds config.max_poll_attempts.

  • ValidationError – If btc_txid is not a valid 64-char hex string, or min_confirmations is given and is below 1.

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
property account_path: str

This wallet’s BIP44 account path, e.g. m/44'/512'/0'.

Single source of truth for the string several callers used to build inline. The _xprv property derives from exactly this path.

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, allow_below_relay_floor=False, allow_overpay=False)[source]

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

fee_rate is refused below Radiant’s effective relay floor unless allow_below_relay_floor is set, and above the overpay ceiling unless allow_overpay is — see build_send_tx(). A sweep has NO change output, so an unintended overpay leaves entirely with the miner, which is why the ceiling exists; it is also why the override has to be reachable.

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

  • to_address (str)

  • fee_rate (int)

  • allow_below_relay_floor (bool)

  • allow_overpay (bool)

Return type:

Transaction

build_send_tx(triples, to_address, photons, *, fee_rate=10000, allow_below_relay_floor=False, allow_overpay=False, 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).

fee_rate is refused below Radiant’s effective relay floor unless allow_below_relay_floor is set — the deliberate opt-out for regtest and chains you control. Unlike RxdWallet, the rate arrives per CALL here, so this is where it has to be judged.

allow_overpay is the mirror opt-out for a rate above the overpay ceiling. A ceiling with no reachable override is its own fund-safety bug: a caller who genuinely means a high rate would be refused outright, and Radiant has neither RBF nor CPFP, so a refusal during a timelock race costs the funds the ceiling was protecting.

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

  • to_address (str)

  • photons (int)

  • fee_rate (int)

  • allow_below_relay_floor (bool)

  • allow_overpay (bool)

  • 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, *, strict=False)[source]

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

Address→key mapping is preserved so signing works correctly per UTXO.

A per-address fetch that fails contributes nothing rather than crashing the whole collection — the caller decides whether the resulting balance is enough — but it is now LOGGED rather than dropped in silence, and strict=True refuses the partial result outright. Use strict when the answer is a claim about all the funds; send_max() does.

Parameters:
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

descriptors(*, checksum=False)[source]

Output-script descriptors for this account’s receive + change chains.

Watch-only safe: the descriptors embed the account xpub, never the xprv or the seed. Note that an xpub still discloses every address this wallet will ever derive on both chains — a larger privacy surface than handing out a single address.

checksum appends the BIP380 suffix. Off by default because Radiant Core rejects the checksummed form; see pyrxd.hd.descriptor.

Parameters:

checksum (bool)

Return type:

AccountDescriptors

external_tip: int = 0
classmethod from_mnemonic(mnemonic, passphrase='', account=0, coin_type=None, *, normalize=True)[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.

normalize controls BIP39 NFKD normalization — see seed_from_mnemonic(). Leave it True unless you are recovering funds from a wallet created before 0.12.0 using a non-ASCII passphrase, which pyrxd then hashed unnormalized. Wrong for every other case: it derives a wallet no other BIP39 implementation can reproduce.

Parameters:
  • mnemonic (str)

  • passphrase (str)

  • account (int)

  • coin_type (int | None)

  • normalize (bool)

Return type:

HdWallet

async get_balance(client, *, strict=False)[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.

A per-address read that fails is logged and contributes zero — so the total is a LOWER BOUND, not a balance. Pass strict=True when the number is being shown to somebody or compared against a threshold.

Parameters:
Return type:

int

async get_utxos(client, *, strict=False)[source]

Return all UTXOs across all known addresses.

A per-address read that fails is logged and contributes nothing; pass strict=True to refuse a partial answer instead (see _read_per_address()).

Parameters:
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, *, normalize=True)[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.

normalize controls BIP39 NFKD normalization of the mnemonic and passphrase — see seed_from_mnemonic(). It matters here because the derived seed is also the wallet file’s AES-GCM decryption key: a wallet saved by pyrxd before 0.12.0 with a non-ASCII passphrase was encrypted under the old, unnormalized seed, and can only be decrypted by reproducing that seed with normalize=False. Leave the default True for every other case. Loading never guesses the mode: a GCM failure raises rather than silently retrying with the other seed, so the legacy mode is only ever entered by explicit opt-in.

Parameters:
  • path (Path)

  • mnemonic (str)

  • passphrase (str)

  • coin_type (int | None)

  • normalize (bool)

Return type:

HdWallet

classmethod load_or_create(path, mnemonic, passphrase='', account=0, coin_type=None, *, normalize=True)[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.

normalize also applies to the load branch — see load() for why it matters there (the seed doubles as the wallet file’s decryption key). False is a fund-recovery escape for pre-0.12.0 wallets with non-ASCII passphrases.

Raises:

ValidationError – if path does not exist and normalize=False. The legacy seed mode exists solely to reach funds already held under a pre-0.12.0 wallet; there is nothing to recover at a path that has no wallet on it. Creating one there instead would mint a brand-new, permanently non-conformant wallet whose mnemonic no other BIP39 implementation can restore — and the likeliest way to land in that branch is a typo in path, which is exactly the failure load_or_create was split out to make visible.

Parameters:
Return type:

HdWallet

master_fingerprint()[source]

The BIP32 master key fingerprint: hash160(master pubkey)[:4].

This is what an output-script descriptor’s key-origin field wants, and it is NOT account_xpub().fingerprint — that attribute is the parent fingerprint (payload bytes 5:9), i.e. the fingerprint of m/44'/<coin>', one level up. The two values differ for every account at depth > 1.

The distinction matters because using the parent fingerprint produces a descriptor that still derives the correct addresses, so nothing appears broken — but it misidentifies the key’s origin, and any consumer that later tries to match the descriptor to a signing device (or to another descriptor from the same seed) will fail to.

Public (no private material leaves): the return value is a truncated hash of a public key.

Return type:

bytes

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

privkey_for_address(address)[source]

Derive the signing key for a known address.

The derivation path is looked up in self.addresses rather than searched for, so this is one ckd chain, not a scan.

Added for pyrxd.glyph.mint.GlyphMinter, which must re-derive the key that spends a Glyph commit output after a crash. It deliberately does not persist the key, only the funding address, so it needs address → key. Keeping that lookup here also keeps the minter’s wallet contract down to two methods (collect_spendable() and this one), which is what makes it practical to drive the minter with a non-HD wallet in a test or a dev script.

Raises:

ValidationError – if the address is not one this wallet derived — the caller has the wrong wallet, and signing with a key that hashes to a different PKH would produce a transaction the network rejects.

Parameters:

address (str)

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, allow_below_relay_floor=False, allow_overpay=False, 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, allow_below_relay_floor=False, allow_overpay=False)[source]

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

Collection is strict: “sweep everything” is a completeness claim, and a sweep built from a view that silently lost an address moves most of the funds while reporting that it moved all of them. Raises NetworkError if any per-address read failed — nothing is broadcast, and a retry (or a different endpoint) sweeps the whole set. Use send() for an amount, which does not make that claim.

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.JsonFilePendingStore[source]

Bases: PendingStore

One 0600 JSON file per pending mint, in a 0700 directory.

A file per record rather than one shared file: two mints in flight would otherwise contend on a read-modify-write, and a torn merge on this path loses the CBOR bytes of whichever record lost.

save() follows the house atomic-write convention (pyrxd.hd.wallet.save, pyrxd.gravity.watch.escalation._store_state): write a temp file opened 0600 by os.open — not chmod’ed afterwards, which would leave a window at the umask’s mercy — fsync it, then os.replace onto the target. The rename is atomic on the same filesystem, so a reader sees either the old record or the new one, never a partial write. It then re-reads the file and compares before returning, because a write that reported success and landed corrupt is indistinguishable from a good one until the reveal fails, by which point the commit is already on-chain.

__init__(directory)[source]
Parameters:

directory (str | PathLike[str])

Return type:

None

delete(commit_txid)[source]

Drop the record. Must not raise if it is already gone.

Parameters:

commit_txid (str)

Return type:

None

property directory: Path

Directory holding the records.

list_pending()[source]

Commit txids with a stored record — the resume list after a crash.

Return type:

list[str]

load(commit_txid)[source]

Return the stored record, or raise PendingMintNotFound.

Parameters:

commit_txid (str)

Return type:

PendingMint

save(pending)[source]

Persist pending, overwriting any record under the same commit txid.

Parameters:

pending (PendingMint)

Return type:

None

class pyrxd.MarginPolicy[source]

Bases: object

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

margin

The required minimum t_rxd - t_btc, 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, rxd_block_interval_fast_s=None, btc_claim_reorg_depth=<factory>, rxd_claim_burial=<factory>, rxd_claim_inclusion=<factory>, rxd_reorg_cost_per_block=None, reorg_cost=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)

  • rxd_block_interval_fast_s (float | None)

  • btc_claim_reorg_depth (Timelock)

  • rxd_claim_burial (Timelock)

  • rxd_claim_inclusion (Timelock)

  • rxd_reorg_cost_per_block (int | None)

  • reorg_cost (ReorgCostMeasurement | 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, eth_finalization_window_s=None)[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.

eth_finalization_window_s is NOT an estimate this class ships — it is a per-chain FACT (pyrxd.eth_wallet.chains), and it is here because the finality gate RAISES without it on any finalized-checkpoint counter leg. An alert-only watchtower watching an ETH swap builds its policy through this constructor, and with the window unset every tick of a healthy swap came out as PAGE_SQUEEZED “verify finality manually”. None keeps the BTC (depth-based) behaviour exactly.

Parameters:
  • block_interval_s (float)

  • require_measured (bool)

  • accept_flat_burial (bool)

  • eth_finalization_window_s (int | None)

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_claim_inclusion=None, rxd_block_interval_s=None, rxd_block_interval_fast_s=None, rxd_reorg_cost_per_block=None, reorg_cost=None, value_at_risk_photons=None, burial_safety_factor=1.0, accept_flat_burial=False, eth_finalization_window_s=None)[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_block_interval_fast_s is the FAST-tail (p10) inter-block measurement, REQUIRED here: every reserve computed by dividing a time span by the interval needs it, and the nominal value under-counts them. Measured Radiant mainnet 2026-08-26: p10 36s against a mean of 293s — a reserve sized with the mean covers about an eighth of its window. (It was p10 43s on 2026-06-02; the drift is downward, which is the direction that under-counts, so re-measure rather than inheriting either figure.) When it is genuinely unknown, pass the same value as rxd_block_interval_s and know that the reserves are then nominal rather than conservative.

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.

eth_finalization_window_s is REQUIRED (non-None) for a finalized-checkpoint (ETH) counter leg and must stay None for a depth-based (BTC) one; take it from pyrxd.eth_wallet.chains.evm_chain_by_id rather than guessing. Its absence here was the reason the watchtower could not set it at all: a field this constructor does not accept is invisible to the reachability guards derived from this signature, and the finality gate then raised on every tick of a healthy ETH swap.

Parameters:
  • margin (Timelock)

  • block_interval_s (float)

  • btc_claim_reorg_depth (Timelock | None)

  • rxd_claim_burial (Timelock | None)

  • rxd_claim_inclusion (Timelock | None)

  • rxd_block_interval_s (float | None)

  • rxd_block_interval_fast_s (float | None)

  • rxd_reorg_cost_per_block (int | None)

  • reorg_cost (ReorgCostMeasurement | None)

  • value_at_risk_photons (int | None)

  • burial_safety_factor (float)

  • accept_flat_burial (bool)

  • eth_finalization_window_s (int | None)

Return type:

MarginPolicy

reorg_cost: ReorgCostMeasurement | None = None
require_measured: bool = False
rxd_block_interval_fast_s: float | None = None
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
rxd_claim_inclusion: Timelock

Blocks allowed for the taker’s claim to be MINED before its burial starts counting (#511). See ESTIMATED_RXD_CLAIM_INCLUSION_BLOCKS. Kept a policy knob rather than a constant because it is the one term here an operator can measure on their own node.

class pyrxd.MarkBuild[source]

Bases: object

A signed, un-broadcast mark transaction.

Parameters:
  • tx – the signed transaction

  • fee – photons paid, from the plain-RXD funding input

  • plan – the checked MarkPlan these bytes were built from — carried so a confirmation prompt can show what is about to be published permanently without re-deriving it

  • from_address – the wallet address that funded the mark. Note this is NOT necessarily the signer: the key that makes the statement is chosen by whoever built the plan, and the fee is paid by whichever plain-RXD UTXO was large enough.

  • has_changeFalse when the whole funding UTXO became the fee

__init__(tx, fee, plan, from_address, has_change)
Parameters:
Return type:

None

serialize()[source]

Raw transaction bytes, ready for await client.broadcast(...).

Return type:

bytes

tx: Transaction
fee: int
plan: MarkPlan
from_address: str
has_change: bool
class pyrxd.MarkPlan[source]

Bases: object

A HashMark record that has been decoded and attested from its own published bytes.

Holding one of these means all of the following are true OF THE BYTES IN op_return_script, not of the object they were built from:

  • they decode as a v2 HashMark (OK);

  • every push is minimally encoded — §4.1 gives a record exactly one valid serialization, and decode_hashmark() treats a non-minimal push as not-a-HashMark rather than a HashMark to repair;

  • the label, if any, is canonical per §5.4 — a non-canonical v2 label makes the record INVALID, because it is inside the signed statement;

  • the signature recovers to the signer the record commits to, against network_genesis.

The checks run in __post_init__, so there is no order of operations that produces an unchecked one. record and attestation are not constructor arguments for the same reason: derived here, they cannot be supplied inconsistently with the bytes.

Parameters:
  • op_return_script – the scriptPubKey that will be published verbatim.

  • network_genesis – the genesis hash, in RPC/display order, of the chain these bytes are FOR. It is not carried by the record; it is part of the signed statement, so the same bytes on another chain are a different statement and do not verify there (§5.6, §2.10). Getting this wrong does not produce a broken transaction — it produces a perfectly relayable record whose claim is false on the chain it lands on.

  • source – what was digested, for a confirmation prompt to show. Local only; no part of it reaches the chain.

__init__(op_return_script, network_genesis='0000000065d8ed5d8be28d6876b3ffb660ac2a6c0ca59e437e1f7a6f4e003fb4', source=None)
Parameters:
  • op_return_script (bytes)

  • network_genesis (str)

  • source (str | None)

Return type:

None

property algorithm: str
property digest_hex: str

Lowercase hex of the digest this mark commits to — §5.3’s one accepted spelling.

property label: str | None

The canonical label, or None when the record carries no label push.

An absent label is a DIFFERENT signed statement from an empty one — §5.6 omits the key entirely rather than writing "" — so this is never "".

network_genesis: str = '0000000065d8ed5d8be28d6876b3ffb660ac2a6c0ca59e437e1f7a6f4e003fb4'
property signer_hash160_hex: str

The committed signer. Equal to attestation.recovered_hash160_hex by construction.

property size_bytes: int

Size of the record on chain. §3.2 caps it at 223.

source: str | None = None
op_return_script: bytes
record: HashMarkRecord

The record as read back OFF op_return_script.

attestation: AttestationResult

The §6.3 verdict a stranger computes, run here before anything is funded.

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_rxd - t_btc >= margin is checked by the coordinator (see swap_coordinator.assert_timelock_margin), not here — but the raw ordering t_rxd <= t_btc in the same unit is rejected at construction as a cheap fail-closed guard. INVERTED 2026-08-31 (#482): the maker holds p and LOCKS the Radiant leg, so that leg carries the LONGER timeout.

THIS NAMED THE REQUIRED ORDERING AS THE REJECTED ONE. #482 appended the sentence above and left the clause before it, so the paragraph said t_rxd > t_btc “is rejected at construction” while __post_init__ refuses t_rxd <= t_btc and its message reads “requires t_rxd > t_btc”. A reader taking the first sentence at face value builds the pre-#482 layout, which lets the maker refund its own leg while p is secret and then claim the counter leg.

__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, token_address='', eth_timeout_unix_s=None, credential_ref=b'')
Parameters:
  • hashlock (bytes)

  • btc_sats (int)

  • radiant_amount (PhotonValue | TokenUnits)

  • 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)

  • counter_chain (str)

  • value_amount (int)

  • token_address (str)

  • eth_timeout_unix_s (int | None)

  • credential_ref (bytes)

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[str, Any])

Return type:

NegotiatedTerms

to_dict()[source]

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

Return type:

dict[str, Any]

token_address: str = ''
value_amount: int = 0
hashlock: bytes
btc_sats: int
radiant_amount: PhotonValue | TokenUnits
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.PrivateSubmitter[source]

Bases: Protocol

Submit a SIGNED raw tx privately and return its tx hash (0x-hex).

The one method EthHtlcContractLeg needs: it hands over the already-signed raw tx bytes for the claim and gets back the tx hash, exactly like EthRpc.send_raw — but off the public mempool. Any object with this method can be injected (a real Flashbots client, a builder’s private endpoint, or a test fake).

__init__(*args, **kwargs)
async submit_raw(raw_tx)[source]
Parameters:

raw_tx (bytes)

Return type:

str

class pyrxd.RadiantBroadcaster[source]

Bases: Protocol

Submit a raw Radiant tx; idempotent on an already-known tx.

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

raw_tx (bytes)

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()).

  • fee_policy – The DeadlineFeePolicy the pre-broadcast affordability gate enforces. Defaults to the reference node’s advertised 0.10 RXD/kB effective relay rate; pass an explicit policy when the node this leg broadcasts to advertises a different effective_minrelaytxfee.

__init__(*, network, taker_pkh, maker_pkh, chain_io, fee_source, min_confirmations=1, audit_cleared=False, fee_policy=None)[source]
Parameters:
  • network (str)

  • taker_pkh (bytes)

  • maker_pkh (bytes)

  • chain_io (RadiantChainIO)

  • fee_source (FeeUtxoSource)

  • min_confirmations (int)

  • audit_cleared (bool)

  • fee_policy (DeadlineFeePolicy | None)

Return type:

None

async claim_asset(record, preimage)[source]

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

Fee-sized against the DEADLINE: the maker’s CSV refund branch opens once the covenant is t_rxd confirmations deep, so t_rxd - confirmations is the number of Radiant blocks in which this claim must be mined, not merely broadcast. The pre-broadcast gate refuses (and pages) if the dispensed fee input cannot meet that requirement — there is no post-broadcast remedy on Radiant.

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 rebroadcast_claim_if_evicted(record, preimage)[source]

Re-broadcast the taker’s claim if it has fallen out of the mempool. Returns the new txid, or None when nothing needed doing.

WHY THIS EXISTS. A non-BIP68-final refund is rejected from the mempool (Radiant Core validation.cpp:724-728), so the maker CANNOT pre-broadcast and a claim already sitting in the mempool at CSV maturity wins the race. The whole safety of the claim window therefore rests on the claim STAYING there — and Radiant has no RBF and no CPFP, so a claim that is evicted cannot be bumped back in. Mempool expiry is about eight hours.

The coordinator broadcast the claim and advanced straight to a completed state, so an eviction was invisible: the maker’s refund became valid at maturity, confirmed, and took both legs while the swap’s own record said it had finished.

Single-shot on purpose — no loop, no clock. The caller drives it on whatever tick it already has, which keeps this testable and keeps clock ownership where the rest of the module puts it.

Returns None when the covenant is already spent (our claim is in the mempool or mined — nothing to do) and when the source ABSTAINS, because an unknown answer must not be treated as “evicted” and turned into a duplicate broadcast.

Parameters:
  • record (SwapRecord)

  • preimage (bytes)

Return type:

str | None

async refund_asset(record)[source]

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

P3 maturity self-check: the covenant’s CSV refund leaf is only spendable once the covenant UTXO is buried t_rxd deep (the BIP68 relative-block timelock the covenant was built with: refund_csv=t_rxd.value, mature at confirmations >= t_rxd.value). Refuse a non-final refund HERE rather than emit a tx a node rejects — under a deadline-pinning mempool “rely on node rejection” is fragile — with an exact “needs N confirmations, has M” message a block-based poller retries on. This guards EVERY refund_asset caller (mutual_refund, maybe_refund_asset_on_maker_stall) at the leg, complementing the coordinator-side height check in maybe_refund_asset_on_maker_stall. (The CLAIM branch has no CSV, so claim_asset is intentionally NOT gated this way.)

Parameters:

record (SwapRecord)

Return type:

str

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

TAKER-side fail-closed gate: is the MAKER’s asset really locked, at the agreed value, buried deep enough, before the taker funds the counter leg? Returns (outpoint, value_photons, confirmations); RAISES on anything else — the taker MUST NOT lock BTC/ETH if this raises. The Radiant twin of pyrxd.btc_wallet.htlc_leg.BitcoinTaprootLeg.verify_counterparty_funded().

WHY: docs/htlc-handshake-wire-format.md HZ-1 states it normatively — “a taker MUST NOT fund the counter leg until it has confirmed the maker’s asset lock on chain, at the agreed scriptPubKey, for the agreed value, at a depth the taker chose.” Nothing else in the handshake gives the taker that. The BTC claim leaf is <H> <makerClaimPk> OP_CHECKSIG with no precondition that the asset was ever locked, and the maker holds both p and the claim key from the moment it publishes the envelope. So a maker that locks NOTHING and simply waits can sweep the taker’s HTLC the instant it appears: the taker’s loss is the full btc_sats, and the FSM’s nominal “taker locks first” ordering is bookkeeping, not a safety guarantee.

What is checked, all fail-closed:

  1. the covenant scriptPubKey is re-derived here from the taker’s own ``terms`` (_build_covenant() — amount, H, t_rxd CSV, both dest hashes, the asset REF), never taken from anything the maker advertises;

  2. that exact SPK holds a funded UTXO, and its ON-CHAIN value equals terms.radiant_amount — an unfunded SPK, a mis-valued one, and an ambiguous UTXO set all raise (RadiantChainIO.find_covenant_utxo());

  3. the funding is buried min_confirmations deep. “Funded” alone is NOT enough: ElectrumX listunspent includes MEMPOOL outputs, so a maker can fund with a replaceable transaction, wait for the taker’s lock, then double-spend the funding away — it still claims the counter leg with p while the vanished covenant leaves the taker nothing to claim. None uses this leg’s configured min_confirmations; the coordinator passes the policy’s RXD burial depth for a real-value swap.

Parameters:
  • terms (NegotiatedTerms)

  • min_confirmations (int | None)

Return type:

tuple[str, int, int]

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.2-amd64'
RPC_PASSWORD = 'pyrxd'
RPC_USER = 'pyrxd'
WALLET = 'devnet'
classmethod build_image(version='v3.1.2', *, 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, extra_args=())[source]

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

extra_args are appended verbatim to the radiantd argv — e.g. ("-swapindex=1",) to serve the RSWP orderbook RPCs (getopenorders / getopenordersbywant). Ignored when an already running container is reused (start with fresh=True to apply).

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:
Return type:

None

stop()[source]

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

Return type:

None

class pyrxd.RevealProof[source]

Bases: object

Parsed reveal proof, mirroring Photonic’s RevealProof type.

__init__(v, p, action, token_ref, cek, cek_hash, hint='')
Parameters:
Return type:

None

classmethod from_dict(d)[source]
Parameters:

d (dict)

Return type:

RevealProof

hint: str = ''
to_dict()[source]
Return type:

dict

v: int
p: list[int]
action: str
token_ref: str
cek: str
cek_hash: str
class pyrxd.RevealValidation[source]

Bases: object

Result of validate_reveal_proof().

  • valid: True iff every check passed

  • error: short human-readable failure reason if valid is False

  • proof: the parsed proof if it was at least well-formed (so the caller can introspect malformed-but-decodable proofs)

__init__(valid, error='', proof=None)
Parameters:
  • valid (bool)

  • error (str)

  • proof (RevealProof | None)

Return type:

None

error: str = ''
proof: RevealProof | None = None
valid: bool
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), and is REFUSED below it unless allow_below_relay_floor says otherwise.

  • allow_below_relay_floor – Accept a fee_rate under Radiant’s effective relay floor. The deliberate, greppable opt-out for regtest and for chains you control, which legitimately relay lower — a default regtest node runs at a tenth of mainnet’s rate. Never a way to make a mainnet wallet stop complaining: every send it builds would be refused by every node, and with no RBF and no CPFP could not be repaired.

  • allow_overpay – The mirror opt-out, for a fee_rate above the overpay ceiling (MAX_FEE_OVERPAY_MULTIPLE x the relay floor). Without it the ceiling is absolute, which is its own fund-safety bug: a deliberate high rate — a fee war, a chain whose floor pyrxd has not been taught, a caller who genuinely wants to outbid — would be refused with no way through, and on a chain with neither RBF nor CPFP a refusal during a timelock race costs the funds the ceiling was protecting.

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

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

  • electrumx_url (str)

  • fee_rate (int)

  • allow_below_relay_floor (bool)

  • allow_overpay (bool)

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

Where the fee headroom comes from

A sweep has no change output, so the only place an extra photon of fee can come from is the single payout. Two options, and this method takes the first deliberately:

  1. Size the fee with headroom up front, so the payout is decided once, before signing, and never moved afterwards. The caller asked for “my whole balance, minus the fee” — an amount defined by the fee — so sizing the fee conservatively is answering the question they asked, not quietly shaving an amount they specified. The headroom is SIG_SIZE_SLACK_BYTES × inputs × fee_rate: at the default rate that is 30_000 photons (0.0003 RXD) per input, worst case, and only the unused part is surrendered to the miner.

  2. Re-measure afterwards and shave the payout to cover a shortfall. Doing that means signing a third time, whose signatures can again be longer, so it either loops or needs its own headroom — and it changes an amount after the caller has been shown it.

build_send_tx() cannot take option 2 at all: there the recipient amount is exact and the only adjustable output is change, so silently reducing the payout would be sending less than was asked for.

The final signed transaction is re-measured either way and the build is refused if it does not clear its own rate.

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 — a pyrxd send-policy floor of 546 photons, not a chain rule (Radiant’s real floor is 1).

  • 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_funding_ref)[source]

MAKER-side fail-closed gate (red-team CRITICAL fix): the maker MUST verify the TAKER-funded counter-leg HTLC binds to the negotiated terms + the maker’s own payout config AFTER the maker has locked the asset and BEFORE the maker reveals p. 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 reveal p if this raises, and recovers the already-locked covenant through its CSV refund. Refusing here costs a swap, never an asset.

WHY THIS EXISTS: the maker commits its own value against a leg the COUNTERPARTY built. The runbook is MAKER-locks-asset-FIRST (the taker will not fund until pre_btc_lock_check step 5 has read the covenant off the Radiant chain), then TAKER-funds-counter, then this. Nothing else in the handshake binds that leg: every other check the maker can run is a re-derivation of what the counter leg SHOULD look like, and re-deriving a target says nothing about what the taker actually funded. This is the only place the maker compares the two against the chain.

Both chains need it, for the same reason and by different mechanics:

  • ETH — there is no pre-fund commitment at all (the contract does not exist until the taker deploys it), so a hostile taker can deploy claimant=self, underfund, or set a bad timeout. EthHtlcContractLeg.verify_funded is the only binding, and it previously ran ONLY inside the taker’s own fund().

  • BTC — the funding ADDRESS is a pure function of terms, but a P2TR scriptPubKey commits to the TAPTREE, not to the output value. So a hostile taker funds the correct, freely-derivable HTLC address with LESS than value_amount and every SPK check still passes. (This method used to REFUSE a BTC counter leg on the grounds that the pre-fund derive==promised gate already bound it. That was wrong twice over: that gate is a self-consistency check between two derivations of the maker’s own terms, and it runs inside the TAKER’s taker_funds_btc, which a hostile taker simply does not call. The amount bind in the same method is likewise the honest taker’s own. Documented as hazard HZ-3 in docs/htlc-handshake-wire-format.md.)

The maker passes ONLY the one untrusted datum the counterparty must supply — the ETH contract ADDRESS, or the BTC funding OUTPOINT (a BtcOutpoint, a BtcHtlcLocator whose outpoint is read and whose other fields are ignored, or "<txid>:<vout>"). The leg builds the EXPECTED leg from the maker’s own config + terms and verifies the chain matches it.

This gate is NOT optional: post_asset_lock_revalidate() requires a verified locator on the record and RE-RUNS the verification at lock time (closing the verify->lock TOCTOU) before it will advance to BOTH_LOCKED, on both chains.

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; audit re-verify HIGH), ignored for BTC. On an ETH timing failure this refuses to advance to BOTH_LOCKED (raises).

THAT SENTENCE USED TO NAME BOTH THE WRONG HAZARD AND THE WRONG PARTY — “against a stalled maker lock … so the taker refunds the counter leg”. #482 inverted the relation, so a LATE covenant lock is now strictly safer, and #628 corrected the same two claims on _assert_eth_lock_timing_still_safe() while this copy kept them. The caller here is the MAKER’s process (the taker phase never calls this method), so a refusal stops the MAKER advancing and its recovery is the CSV refund of its own covenant.

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 WALL-CLOCK margin t_rxd * i_rxd >= t_btc * i_btc + margin * i_btc (this docstring said t_btc - t_rxd >= margin until 2026-09-02 — the pre-#482 direction, in the pre-#567 units, in the gate’s own description of itself). 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()).

  5. The MAKER’S ASSET IS REALLY LOCKED (hazard HZ-1 / threat-model S24) — taker_verify_asset_funding(). Checks 1-4 are all re-derivations of what the swap SHOULD look like; this is the only one that reads the Radiant chain, and without it the taker locks its counter leg against a maker that locked nothing (which then sweeps it with the p it has held since the envelope). Unfunded / mis-valued / shallow / unreadable => fail-closed.

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 resume_interrupted_fund(terms, *, sink, now_unix_s)[source]

Reload a crashed fund from durable storage and complete it.

THE READ SIDE. Without this the durable record was written and never read: every guard the resume path carries — the nonce pin, the fund lock, the seen-store divergence check, the immutable re-bind — was unreachable in production because pending_counter_contract could only ever be set by a test that hand-built a record. A mechanism with no reader is half a mechanism, and this is the missing half.

Fails closed on every disagreement, because the alternative to refusing here is funding a second HTLC while the first holds real value:

  • No record on disk → refuse. A resume with nothing to resume from is a fresh fund, and a fresh fund is taker_funds_btc’s job; silently falling through to it would deploy again.

  • A record with no pending handle → refuse. Either the fund completed (the locator is on the record) or it never started; neither is a resume.

  • Terms that disagree with the record’s → refuse. taker_funds_btc takes terms as an argument and never checks them against the record it is about to act on, so a drifted argument would fund one thing while the record describes another.

Parameters:
  • terms (NegotiatedTerms)

  • sink (Any)

  • now_unix_s (int)

Return type:

SwapRecord

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. The gate’s on-chain asset check (taker_verify_asset_funding()) is RE-RUN here, immediately before the broadcast, which is what closes the verify->lock TOCTOU: a maker can double-spend its covenant funding away in the window between the taker’s check and the taker’s lock. 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_observed_reveal(maker_claim_ref)[source]

Advance BOTH_LOCKED -> SECRET_REVEALED on OBSERVING the maker’s on-chain claim.

The honest TAKER never executes the maker’s claim (that is the maker’s key/action, on a different host); it OBSERVES the reveal on-chain and must then enter the claim flow. The only other path to SECRET_REVEALED is maker_claims_btc() — a MAKER action — so two-party callers previously FABRICATED a SECRET_REVEALED record as a resume seam (scripts/eth_swap_two_host.py) or advanced the FSM directly in tests. This is the first-class taker-side transition that replaces both seams.

It VERIFIES the observed claim is a genuine reveal of THIS swap’s p before advancing — sha256(p) == H scraped from the claim AND the per-swap provenance gate (BTC: the claim spends OUR funding outpoint; ETH: it targets OUR HTLC contract and emits Claimed(p)). A fabricated or cross-swap “reveal” fails closed and does NOT move the FSM.

It deliberately does NOT claim the asset and does NOT run the reorg/finality gate — those stay in taker_scrape_and_claim_asset(), which the caller invokes NEXT (that gate decides SAFE/WAIT/SQUEEZED off the same reveal). maker_claim_ref is the ETH claim tx HASH (str) or the raw BTC claim tx bytes — exactly what taker_scrape_and_claim_asset() takes.

Return type:

SwapRecord

async taker_rebroadcast_claim_if_evicted(p)[source]

Re-broadcast the taker’s claim if it has fallen out of the mempool. Returns the new txid.

The production entry point for the eviction case. A claim only wins the race with the CSV refund by BEING in the mempool when maturity arrives, and Radiant’s mempool expiry is about eight hours with no RBF to bump it back in. Drive this on whatever tick the operator or the watchtower already runs, between the claim and the covenant’s maturity.

Parameters:

p (bytes)

Return type:

str | None

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

async taker_verify_asset_funding(terms)[source]

Fail-closed: the MAKER’s asset must be locked on chain before the taker locks anything.

Returns the verified (outpoint, value_photons, confirmations); RAISES on anything else.

HZ-1 in docs/htlc-handshake-wire-format.md states this as a normative MUST, and until now no library code enforced it — the check existed only inside scripts/btc_swap_two_host.py, so any caller driving SwapCoordinator directly locked its counter leg against nothing. The maker holds both p and the counter-leg claim key from the moment the envelope is published, and the BTC claim leaf carries no precondition that the asset was ever locked, so a maker that locks NOTHING sweeps the taker’s HTLC as soon as it appears: a one-sided taker loss of the full btc_sats.

The Radiant leg re-derives the covenant scriptPubKey from the taker’s OWN terms and reads the chain for it (value bound exactly, depth pinned by _asset_funding_depth()). A leg that cannot perform that read cannot be verified AT ALL, so its absence refuses — mirroring _counter_verify_callable() on the maker side.

Called from pre_btc_lock_check() AND re-run inside taker_funds_btc() immediately before the counter-leg broadcast: re-running is what closes the verify->lock TOCTOU, where a maker double-spends its covenant funding away in the window between the taker’s check and the taker’s lock.

Parameters:

terms (NegotiatedTerms)

Return type:

tuple[str, int, int]

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, pending_counter_contract=None, pending_counter_deploy_tx=None, pending_push_nonce=None, pending_push_tx_hash=None)
Parameters:
  • state (SwapState)

  • terms (NegotiatedTerms)

  • counterchain_locator (BtcHtlcLocator | EthHtlcLocator | None)

  • radiant_covenant_outpoint (str | None)

  • radiant_covenant_spk_hex (str | None)

  • pending_counter_contract (str | None)

  • pending_counter_deploy_tx (str | None)

  • pending_push_nonce (int | None)

  • pending_push_tx_hash (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[str, Any])

Return type:

SwapRecord

pending_counter_contract: str | None = None

An ETH-side HTLC that has been DEPLOYED for this swap but is not yet an accepted funded locator. It exists because a BTC funding address is derivable from terms before any broadcast, while a CREATE address depends on the deployer’s nonce and appears nowhere until the deploy receipt returns. Persisting it is what makes the ERC-20 path’s TWO-transaction fund recoverable: deploy lands, the process dies before the token push or before the locator is returned, and without this the only reference to a contract that may hold real USDC is an exception string. refund() after the timeout can always recover the value — but only if the operator still knows the address, and reconstructing a CREATE address by hand is not a recovery procedure. Also covers the native leg, whose payable constructor is one transaction but which can still die between the deploy receipt and verify_funded.

pending_counter_deploy_tx: str | None = None

The deploy transaction of pending_counter_contract. Persisted alongside the address because a resume must rebuild a full locator, and the watchtower’s claim-status path reads this hash — the “0x” + “00”*32 placeholder expected_locator uses for an unknown deploy would silently break it. Unrecoverable after the fact, like the address itself.

pending_push_nonce: int | None = None

a second transaction at a recorded nonce is REJECTED — “nonce too low” once mined, “transaction already imported” while pending — so two resumers, or a resume racing its own still-pending push, deliver the value once and only once. That rejection is a property of the chain rather than of a lock, so unlike flock it holds across hosts, filesystems, and a copied keys directory. Persisting it before the broadcast is what makes it usable on a retry.

THIS USED TO SAY “a REPLACEMENT, never an addition”. Exactly-once here comes from the rejection, not from replacing: replacing needs BOTH EIP-1559 fee fields raised past the pending transaction’s, which _base_tx’s basefee_headroom cannot do (it never touches the tip). The same overclaim was corrected in erc20_leg.py for #515 and left here — the fix-the-class rule, missed once. pyrxd.eth_wallet.replacement now prices a real one. See docs/solutions/design-decisions/nonce-pinning-makes-erc20-funding-idempotent.md, whose “What this does NOT solve” section was right about this all along.

Type:

The sender nonce the token push is PINNED to. Measured (2026-08-24, anvil)

pending_push_tx_hash: str | None = None

The token push’s transaction HASH, recorded BEFORE it is broadcast (the hash is keccak of the bytes we signed, so it needs no receipt — see EthHtlcContractLeg._sign_tx).

Without it a resume cannot READ the pending transaction back, and therefore cannot price a replacement against its fees — eth_getTransactionByHash needs the hash, and txpool_content is non-standard and absent from most public endpoints. That missing read is what blocked the resume carve-out in #515 and the idempotent-funding direction in #504 item 1, not the pricing arithmetic.

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[str, Any]

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

Clears pending_counter_contract DELIBERATELY: that field exists to reference a contract that may hold value but is not yet an accepted locator, and once the locator is attached it carries the address itself. Leaving a stale “pending” handle behind would point recovery at a swap that no longer needs it.

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.TimelockMintBuild[source]

Bases: object

Everything build_timelock_mint() produced, and what to do with each part.

  • metadata — hand this to GlyphClient.mint_nft / mint_timelocked_nft. It is the GlyphMetadata view of stub, built from it rather than beside it so the two cannot drift.

  • stub — the same envelope in Photonic’s own shape. metadata.to_cbor_dict() and stub.to_dict() are equal dicts; the stub is the form to compare against Photonic vectors.

  • ciphertext — the encrypted payload. It does not go on chain: only its plaintext hash, size and chunk count do (main). Publish or store these bytes yourself, or nobody can decrypt anything after the reveal.

  • cek — the 32-byte key. Persist it off chain, encrypted at rest. Losing it loses the reveal; leaking it reveals the content early, and neither is repairable.

  • cek_hash — the "sha256:<hex>" commitment that went on chain. This is what a reveal is checked against.

cek is repr=False for the reason TimelockMintResult documents at length: a default dataclass repr puts the key verbatim into every print, f-string and log line that touches the object, and the printed form is a working decryption key.

__init__(metadata, stub, ciphertext, cek_hash, cek)
Parameters:
  • metadata (GlyphMetadata)

  • stub (EncryptedContentStub)

  • ciphertext (ChunkedCiphertext)

  • cek_hash (str)

  • cek (bytes)

Return type:

None

metadata: GlyphMetadata
stub: EncryptedContentStub
ciphertext: ChunkedCiphertext
cek_hash: str
cek: bytes
exception pyrxd.TimelockNotExpired[source]

Bases: ValidationError

Refusing to publish the CEK before unlock_at.

Revealing early does not fail — it works, and destroys the only property the token exists to provide. It cannot be undone: the key is on a public chain.

class pyrxd.TimelockParams[source]

Bases: object

Parameters for adding a TIMELOCK to a Glyph mint.

Matches Photonic’s TimelockParams type.

__init__(mode, unlock_at, hint='')
Parameters:
Return type:

None

hint: str = ''
mode: Literal['block', 'time']
unlock_at: int
class pyrxd.TimelockRecipient[source]

Bases: object

One party who may open the content WITHOUT waiting for the reveal.

The CEK is wrapped to public_key (X25519) and the wrap goes on chain in crypto.recipients, so the holder of the matching private key decrypts as soon as the token is minted. The timelock gates everyone else: the reveal transaction is what publishes the CEK to the public.

kid is a free-form label for the wrap (“auctioneer-key-1”). It is operator text, carried verbatim on chain, and authenticates nothing.

__init__(kid, public_key)
Parameters:
Return type:

None

kid: str
public_key: bytes
class pyrxd.TimelockRevealPlan[source]

Bases: object

Exactly what a reveal would publish, and the checks it already passed.

Produced by plan_timelock_reveal(), which raises rather than returning a plan that would be wrong to broadcast — so holding one of these means the CEK matched the on-chain commitment and (unless early_override is set) the timelock has expired.

cek is not a field. It is in proof.cek because that IS the published payload, and hiding it in a structure whose whole purpose is to show the operator what goes on chain would be theatre.

__init__(token_ref, op_return_script, proof, commitment, mode, unlock_at, unlocked, remaining, early_override=False, judged_at=None)
Parameters:
  • token_ref (str)

  • op_return_script (bytes)

  • proof (RevealProof)

  • commitment (str)

  • mode (str)

  • unlock_at (int)

  • unlocked (bool)

  • remaining (int)

  • early_override (bool)

  • judged_at (int | None)

Return type:

None

early_override: bool = False

True when this plan was built for a still-locked token because the operator passed allow_early. Carried so the confirmation prompt can say so.

judged_at: int | None = None

THE CLOCK READING THE GATE ACTUALLY COMPARED AGAINST — the tip height for a "block" lock, the tip header’s unix timestamp for a "time" one, and None when no clock for this spec’s mode was supplied.

Carried because the number that decides whether a key becomes public was, until this field existed, never shown to anyone. GlyphClient.plan_timelock_reveal takes it from an ElectrumX server, which no part of this SDK authenticates: nothing checks the proof of work behind the height, links the header to a known one, or asks a second endpoint. A server that overstates the tip therefore decides an irreversible publication, and a server that lags refuses an honest holder — and neither shows up in unlocked alone. An operator who can see “tip 812,340” against “opens at 900,000” can notice; one shown only “opens at 900,000” cannot.

None is not a stand-in for 0, and a renderer must not turn it into a distance. It means the gate could not evaluate this lock at all, in which case remaining is 0 by default rather than by measurement — and “0 blocks short of the unlock point” is not a hedge but the strongest possible claim, that you are exactly on time. Anything shown to a person from this field says which of the two it is.

token_ref: str
op_return_script: bytes
proof: RevealProof
commitment: str

The "sha256:<hex>" the mint committed to, and what cek was checked against.

mode: str
unlock_at: int
unlocked: bool

True when the caller’s clock says the lock has expired.

remaining: int

Blocks (mode "block") or seconds (mode "time") still to go. 0 when unlocked.

class pyrxd.TimelockSpec[source]

Bases: object

Photonic-compatible timelock spec embedded in crypto.timelock.

See REP-3009. The on-chain cek_hash here is the same value as the parent CryptoMetadata.cek_hash — it’s duplicated inside the timelock object for clear authentication of the reveal transaction.

__init__(mode, unlock_at, cek_hash, hint='')
Parameters:
Return type:

None

classmethod from_dict(d)[source]
Parameters:

d (dict)

Return type:

TimelockSpec

hint: str = ''
to_dict()[source]
Return type:

dict

mode: Literal['block', 'time']
unlock_at: int
cek_hash: str
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 photons (RXD’s smallest unit) — this is a Radiant client.

On Radiant this IS the Glyph FT token quantity when the output carries an FT ref: 1 photon = 1 token unit (docs/concepts/radiant-fts-are-on-chain.md). OP_REFVALUESUM_OUTPUTS sums ref-bearing outputs’ native nValue (Radiant-Core src/script/interpreter.cpp), and FtUtxo REFUSES value != ft_amount because such an output cannot exist on chain.

An earlier revision of this docstring said the opposite — that “1000 tokens can sit on 546 photons of ordinary dust”. That is the Bitcoin colored-coin model (Atomicals/Runes), and it is wrong here. The claim originated in issue #505, was written into this docstring, and was then cited back as corroboration for #505 — the issue and the doc confirming each other while the chain said otherwise.

Type:

pyrxd.security.units.PhotonValue

height

Block height at which the output was confirmed (0 = unconfirmed). A HEIGHT, never a confirmation count. Both are non-negative ints, so a producer that stores confs here type-checks — and inverts every age ordering built on the field, because ascending height is oldest-first while ascending confs is NEWEST-first. The mainnet ssh-tr shim did exactly that, which flipped find_covenant_utxo’s earliest-confirmed anti-poisoning rule into a poison-selecting rule on the real-value path.

Type:

pyrxd.security.units.ChainHeight

Both fields are unit-TAGGED (:mod:`pyrxd.security.units`), so a producer that
stores a confirmation count in ``height`` or a token count in ``value`` is now
a mypy error at the construction site rather than a code review that has to notice.
The tags are :func:`typing.NewType` aliases
Type:

zero runtime cost, no validation, no

behaviour change. The behavioural half of the contract stays where it was
Type:

every

producer is driven through its real code path by ``tests/test_utxo_record_units.py``
register any new producer there with a units test as well as tagging it here.
__init__(tx_hash, tx_pos, value, height)
Parameters:
  • tx_hash (str)

  • tx_pos (int)

  • value (PhotonValue)

  • height (ChainHeight)

Return type:

None

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

Bases: RxdSdkError

Raised when input fails a trust-boundary validation check.

class pyrxd.WrappedCEK[source]

Bases: object

A CEK wrapped to one recipient via X25519 ECDH + HKDF + XChaCha20-Poly1305.

Matches Photonic’s EncapsulatedSecret shape for the non-PQ path plus the AEAD-encrypted CEK ciphertext.

  • wrapped_cek: 72 bytes = nonce(24) || ciphertext(32) || tag(16)

  • ephemeral_pubkey: 32-byte X25519 ephemeral pubkey

__init__(wrapped_cek, ephemeral_pubkey)
Parameters:
Return type:

None

wrapped_cek: bytes
ephemeral_pubkey: bytes
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, fee_policy=None)[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, and it is checked against the node’s min-relay floor for the completed, signed size before this returns. It used to be taken on trust (fee >= 0, in _balance_and_add_change), which on the taker’s side means paying for the maker’s asset in a transaction no node will relay — and Radiant has neither RBF nor CPFP, so the taker’s funding UTXOs are then held until mempool expiry with nothing received.

fee_policy overrides the rate that floor is derived from, defaulting to DEFAULT_RADIANT_DEADLINE_FEE_POLICY; regtest callers and the CLI’s deliberately sub-floor sizing passes pass their own.

Raises:

InsufficientFundsError – If fee is below that floor.

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, *, normalize=True)[source]

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

Parameters:
  • normalize (bool) – See seed_from_mnemonic(). Leave True unless recovering a wallet created before 0.12.0 with a non-ASCII passphrase.

  • 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, *, normalize=True)[source]

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

Parameters:
  • normalize (bool) – See seed_from_mnemonic(). Leave True unless recovering a wallet created before 0.12.0 with a non-ASCII passphrase.

  • mnemonic (str)

  • lang (str)

  • passphrase (str)

  • prefix (str)

  • path (str)

  • network (Network)

Return type:

Xprv

async pyrxd.broadcast_hashmark_mark(client, build)[source]

Send a built mark and return the txid OF THE BYTES THAT WERE SIGNED.

Split from build_hashmark_mark() for the reason pyrxd.glyph.client.GlyphClient.broadcast_timelock_reveal() documents: a caller that showed someone a build must send THOSE bytes, not rebuild and send a second transaction after the prompt — a confirmation showing one artifact and sending another is worse than no confirmation, because it looks like one.

The txid comes from _confirmed_txid, which compares the server’s echo against hash256 of the signed bytes and RAISES on a mismatch. That helper is imported rather than re-implemented even though it lives under glyph: it is structural (its own protocol asks only for .tx) and explicitly not Glyph-specific, and a second copy of a “do not believe the server’s txid” check is exactly the kind of duplicate that drifts. A mark carries no value, so the failure it prevents is not a lost coin — it is an operator who believes a file was marked at a height where nothing was ever published, which for a timestamping format is the whole product.

Parameters:
  • client (Any)

  • build (MarkBuild)

Return type:

str

async pyrxd.build_hashmark_mark(wallet, plan, *, client, fee_rate, allow_overpay=False, allow_below_relay_floor=False)[source]

Wrap a checked mark plan in a funded, signed transaction. Does not broadcast.

Takes a MarkPlan, never a raw script — see this module’s docstring for the two things a script: bytes parameter would have let a caller skip. The isinstance guard below is what makes that annotation mean something at runtime: without it the one door worth closing, a caller assembling their own object with the right attribute names, is wide open and mypy-clean.

The mark publishes data, not value: output 0 is the OP_RETURN at value 0 and the fee comes from one plain-RXD input, with change returning to the funding address. find_plain_rxd_utxo() verifies each candidate’s on-chain script is a bare P2PKH, so a token-bearing UTXO is never spent here — burning an NFT to publish a hash about a file would be a memorable way to close this issue.

Raises:
  • ValidationErrorplan is not a MarkPlan, or the fee rate is out of bounds, or the signed transaction does not pay for its own size.

  • InsufficientFundsError – no plain-RXD UTXO large enough. Raised before anything is signed.

Parameters:
  • wallet (Any)

  • plan (MarkPlan)

  • client (Any)

  • fee_rate (int)

  • allow_overpay (bool)

  • allow_below_relay_floor (bool)

Return type:

MarkBuild

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.build_timelock_mint(*, name, content_type, plaintext, params, cek=None, recipients=(), locator=None)[source]

Encrypt plaintext and build the mint envelope that commits to its key.

This is the function EncryptedContentStub’s docstring has always told callers to construct through. It did not exist; the docstring named it anyway, and the invariants it promised — main.hash is the hash of the plaintext, crypto.cek_hash and crypto.timelock.cek_hash are both the hash of the key that encrypted it — were left to whoever assembled the stub by hand.

They are the invariants that matter. main.hash is the AAD prefix decrypt_chunked() authenticates every chunk against, so a stub whose main.hash is not sha256(plaintext) yields a token that cannot be decrypted even with the right key. crypto.timelock.cek_hash is the only thing a published CEK is ever checked against. A mint is not repairable, so neither mistake has a second chance — which is why they are enforced by construction here rather than documented.

Steps, all Photonic-compatible:

  1. encrypt with chunked-aead-v1 (encrypt_chunked())

  2. wrap the CEK to each recipient over X25519, with the CEK-hash commitment as AAD (REP-3006 — wrap_cek_x25519())

  3. assemble the [NFT, ENCRYPTED] stub

  4. add the timelock through add_timelock_to_metadata(), which appends TIMELOCK and writes the commitment

Parameters:
  • name (str) – the token’s display name.

  • content_type (str) – MIME type of the plaintext. Recorded twice on chain, as the envelope’s type and as main.type, matching Photonic.

  • plaintext (bytes) – the bytes being sealed. The ciphertext is returned to the caller and does NOT go on chain.

  • params (TimelockParams) – mode ("block" / "time"), unlock_at, optional hint.

  • cek (bytes | None) – the 32-byte content-encryption key. Generated with :func:`secrets.token_bytes` when omitted, which is the right default — a caller supplying one is usually reusing a key, and a reused CEK means revealing one token reveals every other token sealed with it.

  • recipients (Sequence[TimelockRecipient]) – parties who may decrypt immediately, without the reveal. Empty means the reveal transaction is the only way in.

  • locator (str | None) – optional off-chain pointer to the ciphertext (a URL, an IPFS URI). Recorded as crypto.locator; nothing verifies it.

Returns:

TimelockMintBuild — the metadata to mint, the ciphertext to publish, and the CEK to keep.

Raises:
  • ValueErrorcek is not 32 bytes, or a recipient key is not a 32-byte X25519 public key.

  • ValidationErrorname or content_type is empty.

Return type:

TimelockMintBuild

pyrxd.ckd(xkey, path)[source]

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

512 is Radiant’s SLIP-0044 coin type and is what pyrxd.constants.BIP44_DERIVATION_PATH uses. The examples here used to show coin type 0, which is BITCOIN’s — following them derives a wallet whose addresses are not the ones Photonic >= v3.0.0 or Tangem will show for the same mnemonic. Coin types 0 (Photonic <= v2.x, Electron-Radiant, Chainbow) and 236 (pre-#14 pyrxd) are also in use in the Radiant ecosystem for historical reasons — see pyrxd.hd.discovery, which scans all three — but 512 is the one to derive NEW wallets at.

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.decrypt_chunked(chunked, key, plaintext_hash)[source]

Decrypt a chunked ciphertext and return the concatenated plaintext.

plaintext_hash MUST be the SHA-256 commitment from the on-chain metadata — it’s used as the AAD prefix for every chunk. Passing the wrong hash fails decryption on chunk 0 (tag mismatch).

The recovered plaintext is also hashed and compared to plaintext_hash as a final self-consistency check; mismatch raises ValueError.

Parameters:
  • chunked (ChunkedCiphertext)

  • key (bytes)

  • plaintext_hash (bytes)

Return type:

bytes

pyrxd.encrypt_chunked(plaintext, key)[source]

Encrypt plaintext with the Photonic chunked-aead-v1 scheme.

Each chunk gets a fresh random nonce; AAD per chunk is sha256(full_plaintext) || big-endian-uint32(chunk_index).

Output is NOT byte-deterministic across calls (random nonces). For interop testing, decrypt a Photonic-generated chunked ciphertext via decrypt_chunked() and assert the recovered plaintext matches.

Parameters:
Return type:

ChunkedCiphertext

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.get_unlock_remaining(metadata, *, current_block=None, current_time=None)[source]

Return the number of blocks (mode=’block’) or seconds (mode=’time’) remaining until unlock. Returns 0 if already unlocked or not TIMELOCK.

Like is_unlocked(), requires the appropriate clock value to actually compute a number — returns 0 if it can’t determine, and accepts either metadata shape.

Parameters:
  • metadata (EncryptedContentStub | GlyphMetadata)

  • current_block (int | None)

  • current_time (int | None)

Return type:

int

pyrxd.hashmark_mark_funding_bar(op_return_script, fee_rate)[source]

Photons a plain-RXD UTXO must hold to fund one mark, at fee_rate.

Modelled on the no-change shape, for the reason pyrxd.glyph.transfer.nft_transfer_funding_bar() documents: Transaction.fee drops the change output when the funding cannot also cover it, so the smallest UTXO that works is the one paying for the ONE-output transaction. Sizing against the larger shape would refuse funding that in fact relays, which is its own bug.

Parameters:
  • op_return_script (bytes)

  • fee_rate (int)

Return type:

int

pyrxd.is_unlocked(metadata, *, current_block=None, current_time=None)[source]

Return True iff the timelock has expired according to the caller’s view of chain state.

For mode="block" the caller must supply current_block (e.g. from an ElectrumXClient’s tip-height query). For mode="time" the caller supplies current_time (a unix timestamp — typically the latest block’s MTP for strict consensus alignment, but time.time() is acceptable for UI hints).

Accepts either metadata shape — see _protocols_and_spec().

Returns True if the token is not TIMELOCK-marked at all. Returns False if the required clock value wasn’t supplied for the token’s mode — i.e. the caller can’t determine unlock status without it.

Parameters:
  • metadata (EncryptedContentStub | GlyphMetadata)

  • current_block (int | None)

  • current_time (int | None)

Return type:

bool

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

str

pyrxd.parse_reveal_proof_script(script)[source]

Parse a reveal-proof OP_RETURN script. Returns None if the script is not a well-formed Glyph TIMELOCK reveal proof.

Decodes the bridge fixture’s op_return_script_hex correctly (verified via the test test_parse_photonic_reveal_script).

Parameters:

script (bytes)

Return type:

RevealProof | None

pyrxd.plan_hashmark(digest, private_key, *, label=None, algorithm_id=1, network_genesis='0000000065d8ed5d8be28d6876b3ffb660ac2a6c0ca59e437e1f7a6f4e003fb4', source=None)[source]

Sign digest into a v2 HashMark record and check it the way a stranger will.

A thin composition of encode_hashmark() and MarkPlan, and the ONE supported way to get bytes into build_hashmark_mark(). See MarkPlan for what holding the result means and encode_hashmark() for every refusal on the way in — in particular that label must already be canonical: canonicalize_label() produces the canonical spelling, and §5.4 requires the caller SHOW THE USER that spelling before it is signed, which is exactly the step a library cannot do for them.

Parameters:
  • digest (bytes)

  • private_key (PrivateKey)

  • label (str | None)

  • algorithm_id (int)

  • network_genesis (str)

  • source (str | None)

Return type:

MarkPlan

pyrxd.plan_hashmark_for_file(path, private_key, *, label=None, algorithm_id=1, network_genesis='0000000065d8ed5d8be28d6876b3ffb660ac2a6c0ca59e437e1f7a6f4e003fb4')[source]

plan_hashmark() over the digest of a file, with source set to its path.

The file’s bytes do not go on chain and are not kept: only the digest is signed. Marking a file you have not read is a hazard the format cannot help with — a digest proves integrity, never that the contents are true or yours.

Parameters:
  • path (Path | str)

  • private_key (PrivateKey)

  • label (str | None)

  • algorithm_id (int)

  • network_genesis (str)

Return type:

MarkPlan

pyrxd.plan_timelock_reveal(metadata, *, token_ref, cek, current_block=None, current_time=None, hint='', allow_early=False)[source]

Check a reveal against the token that is being revealed, then build its script.

This is the only supported way to produce a publishable reveal script. create_reveal_proof() builds a proof from a CEK and a ref alone; it cannot check either against the token, because it is never given the token. That is the whole gap: both permanent mistakes on this path are invisible to a function that only sees the key.

  • A CEK that is not the one committed to. create_reveal_proof happily emits a self-consistent proof for any 32 bytes — sha256(cek) == proof.cek_hash holds for the wrong key just as well as the right one. Only the mint’s crypto.timelock says which key was right, so the comparison has to happen where the metadata is.

  • A reveal published before unlock_at, which does not fail. It succeeds, and the sealed content is public years early.

So the checks are here, in the function that returns the bytes, rather than beside it in a caller that has to remember them. Every entry point that can broadcast a reveal — GlyphClient.build_timelock_reveal, GlyphClient.reveal_timelock and pyrxd glyph timelock-reveal — goes through this, and none of them takes a pre-built script.

The script this returns is then parsed back with parse_reveal_proof_script() and run through validate_reveal_proof() against the same commitment, so what is checked is the bytes that will actually be published rather than the object they were built from.

Parameters:
  • metadata (TimelockMetadata) – the token’s mint metadata — either shape (see pyrxd.glyph.timelock._protocols_and_spec()). decode_payload on the mint’s CBOR gives you one; build_timelock_mint gives you the other.

  • token_ref (str) – "<64-hex txid>:<vout>" of the token being revealed.

  • cek (bytes) – the 32-byte key to publish.

  • current_block (int | None) – chain tip, for a mode="block" lock.

  • current_time (int | None) – unix seconds, for a mode="time" lock.

  • hint (str) – optional operator note carried in the proof.

  • allow_early (bool) – publish anyway, before unlock_at. The refusal exists because the mistake is unrepairable, not because early reveal is never wanted — a seller who decides to open a sealed lot early has honest work to do here. It must be asked for explicitly, and the returned plan records that it was.

Raises:
  • TimelockNotExpired – the lock has not expired (or cannot be judged, because the clock for its mode was not supplied) and allow_early is False.

  • CekCommitmentMismatchsha256(cek) is not the token’s committed hash.

  • ValidationError – the metadata carries no timelock spec to check against, the commitment it carries is not a readable "sha256:<hex>" (which a third-party mint can be — the decoder stores that string raw), or the proof this function built does not validate.

Return type:

TimelockRevealPlan

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', *, normalize=True)[source]

Derive the 64-byte BIP39 seed from a mnemonic (+ optional passphrase).

BIP39 requires the mnemonic sentence and the passphrase to be NFKD normalized before they enter PBKDF2. Without it, two spellings that a user cannot tell apart – “café” with a precomposed U+00E9 versus the same word as “e” + combining U+0301 – derive different seeds, and therefore entirely different wallets.

Parameters:
  • normalize (bool) – Leave True (the default) for spec-conformant, cross-wallet-compatible seeds. Pass False only to reproduce the non-conformant seed pyrxd produced before 0.12.0, which is the recovery path for anyone who funded a wallet using a non-ASCII passphrase under the old behavior. It is not interoperable with any other BIP39 implementation – see docs/how-to/recover-funds-across-wallet-paths.md.

  • mnemonic (str)

  • lang (str)

  • passphrase (str)

  • prefix (str)

Return type:

bytes

Note

normalize=False is inert for a passphrase that is already in NFKD form, which includes every pure-ASCII passphrase (the overwhelmingly common case) and both wordlists pyrxd ships. For those inputs the two modes return byte-identical seeds.

pyrxd.unwrap_cek_x25519(wrapped_cek, ephemeral_pubkey, recipient_privkey, aad=b'')[source]

Recover a CEK wrapped via wrap_cek_x25519() (or Photonic’s wrapCEK with the non-PQ X25519 path).

Raises ValueError if any of the inputs are wrong: wrong privkey (ECDH gives a different shared secret → wrong KEK → AEAD tag fails), wrong AAD, tampered wrapped_cek bytes, or malformed sizes.

Parameters:
Return type:

bytes

pyrxd.validate_reveal_proof(proof, *, expected_token_ref, expected_cek_hash=None)[source]

Validate a parsed reveal proof’s correctness.

Checks performed:
  1. action == "reveal" (re-checked even though the parser already did)

  2. token_ref == expected_token_ref

  3. sha256(cek) == cek_hash (self-consistency — proves the CEK the proof publishes actually hashes to the commitment in the proof itself)

  4. If expected_cek_hash is provided, cek_hash matches it (this is the on-chain commitment from the original mint)

Returns RevealValidation with valid=True on success.

Parameters:
  • proof (RevealProof)

  • expected_token_ref (str)

  • expected_cek_hash (str | None)

Return type:

RevealValidation

pyrxd.verify_cek_reveal(cek, commitment)[source]

Return True iff sha256(cek) matches the commitment.

Accepts the commitment either as a "sha256:<hex>" string or raw 32-byte hash. Constant-time comparison.

Parameters:
Return type:

bool

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

pyrxd.wrap_cek_x25519(cek, recipient_pubkey, aad=b'')[source]

Wrap a 32-byte CEK for an X25519 recipient.

Generates a fresh ephemeral keypair and a random 24-byte nonce internally; output is non-deterministic. Recipient unwraps via unwrap_cek_x25519() using their X25519 private key.

aad is bound to the AEAD wrap — passing different aad to unwrap fails decryption. Photonic uses the on-chain CEK hash commitment bytes here per REP-3006.

Parameters:
Return type:

WrappedCEK

pyrxd.x25519_public_key(privkey)[source]

Derive the 32-byte X25519 public key from a 32-byte private scalar.

Matches @noble/curves’ x25519.getPublicKey(privkey) byte-for-byte.

Parameters:

privkey (bytes)

Return type:

bytes