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:
objectState of a live Gravity MakerOffer on Radiant.
Returned by
GravityMakerSession.create_offer()and required by all subsequent lifecycle methods.- offer
The original
GravityOffercovenant parameters.
- maker_offer_result
Raw tx details from
build_maker_offer_tx.
- offer_txid
Radiant txid of the confirmed MakerOffer funding output.
- Type:
- offer_vout
Output index of the MakerOffer P2SH UTXO (always 0).
- Type:
- offer_photons
Photons locked in the MakerOffer P2SH output.
- Type:
- __init__(offer, maker_offer_result, offer_txid, offer_vout, offer_photons)
- Parameters:
offer (GravityOffer)
maker_offer_result (MakerOfferResult)
offer_txid (str)
offer_vout (int)
offer_photons (int)
- Return type:
None
- offer: GravityOffer
- maker_offer_result: MakerOfferResult
- offer_txid: str
- offer_vout: int
- offer_photons: int
- class pyrxd.AddressRecord[source]
Bases:
objectAddressRecord(address: ‘str’, change: ‘int’, index: ‘int’, used: ‘bool’)
- __init__(address, change, index, used)
- address: str
- change: int
- index: int
- used: bool
- class pyrxd.Asset[source]
Bases:
objectOne side of a trade: plain RXD, or a Glyph fungible token.
amountis in photons. For an FT this is also the token-unit count (Radiant convention: 1 photon = 1 FT unit).refis the FT’s genesis/commit outpoint (the permanent token identity) and is required for — and only for —kind == "ft".- __init__(kind, amount, ref=None)
- kind: Literal['rxd', 'ft']
- amount: int
- class pyrxd.CappedFeeWalletSource[source]
Bases:
objectA capped
FeeUtxoSourceover a fixed pre-funded pool.- Parameters:
pool – The pre-funded inventory: small plain-RXD
FeeInputUTXOs 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]
- 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 exceedtotal_cap_photons. Dispense-once: the returned UTXO is never returned again.- Return type:
- property remaining_inputs: int
Count of pool UTXOs not yet dispensed (physical inventory; some may be blocked by the cap — see
remaining_photonsfor the actually-spendable budget).
- property remaining_photons: int
Photons that
next_fee_input()will actually dispense from here — the in-order prefix of remaining inputs that fits under the cap. Dispensing is in-order and stops at the first input that would exceed the cap (head-of-line), so this is 0 once the next input no longer fits, giving a tower an honest “page now” signal that matches dispense behaviour.
- property total_cap_photons: int
The configured cumulative software ceiling.
- class pyrxd.CoordinatorConfig[source]
Bases:
objectTunables for
SwapCoordinator.- __init__(margin_policy, maker_stall_safety_window_blocks=6, min_ref_confirmations=6, accept_nondurable_seen=False, accept_estimated_eth_margins=False, min_credential_confirmations=6)
- accept_estimated_eth_margins: bool = False
- accept_nondurable_seen: bool = False
- maker_stall_safety_window_blocks: int = 6
- min_credential_confirmations: int = 6
- min_ref_confirmations: int = 6
- margin_policy: MarginPolicy
- class pyrxd.CounterChainLeg[source]
Bases:
ABCAbstract 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.locatoris a chain-specific durable record (BtcHtlcLocator/EthHtlcLocator) carrying no secret.claim_artifactis 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).
- abstractmethod async fund(terms)[source]
Lock the counter-chain value into a fresh HTLC; return its durable locator.
MUST NOT return a locator until the funding is confirmed/irreversible enough that treating the leg as “locked” is safe (e.g. ETH waits for the deploy tx status==1).
- 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).
- 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.
- 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.
- 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’).
- class pyrxd.EthLeg[source]
Bases:
objectCoordinator-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_bearinggate, and gated byrequire_audit_cleared.refund_to (claim_to /) – The maker’s ETH address (receives ETH on
claim(p)) and the taker’s ETH address (receives ETH onrefund()). These live on the leg, not inNegotiatedTerms.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]
- 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
pfrom it (tx.to+ a successful receipt + aClaimed(p)log from the contract). Binds the SECRETp, not the publicH. Fail-closed; seeEthHtlcContractLeg.assert_claim_provenance().
- async claim(locator, preimage)[source]
- async claim_finality_verdict(tx_hash)[source]
The point-in-time ETH finality verdict (FINAL once at/under the
finalizedcheckpoint, else NOT_YET_FINAL_LIVE) the reorg gate consumes.- Parameters:
tx_hash (str)
- Return type:
CounterClaimFinality
- 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 negotiatedterms(hashlock, value_amount, chain id) — it does NOT trust any counterparty-supplied locator.verify_counterparty_funded()checks the on-chain contract atcontract_addressmatches 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_hashis informational (not bound on-chain).
- 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.
- async fund(terms)[source]
Deploy + fund the ETH HTLC from the negotiated terms, then run the post-deploy binding gate (verify_funded) BEFORE returning — so the coordinator never tells the maker to lock RXD against a wrong/attacker/under-funded contract.
DEPLOY-THEN-VERIFY ATOMICITY (audit completeness): unlike the BTC P2TR path (whose funding address is pre-derived and verified BEFORE any broadcast), an ETH HTLC contract does not exist until it is deployed, so
verify_fundednecessarily 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 immutablerefundee(the taker) viarefund()aftertimeout. To make the stranded deploy recoverable WITHOUT a chain rescan, we stash the deployed locator onself.last_funded_locatorBEFORE verify — so a caller that seesfundraise still has the contract address to drive the timelock refund. (A full coordinator-record-level recovery is a Phase-4 item.)- Return type:
EthHtlcLocator
- locked_amount(locator)[source]
The funded amount the coordinator binds to
terms.value_amount— wei for ETH.- Parameters:
locator (EthHtlcLocator)
- Return type:
- scrape_secret(claim_artifacts, hashlock)[source]
Recover
pfrom the maker’s ETH claim — fail-closed bysha256 == Hover the candidate blobs (calldata + log data) the caller fetched viafetch_claim_artifacts(). Pure (no network), mirroring the BTC leg’s pure witness scrape.
- 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_addressbinds to the maker’s EXPECTED terms BEFORE the maker locks the asset.The taker deploys the ETH HTLC FIRST and the maker locks RXD SECOND, so the maker MUST independently verify the on-chain contract (claimant==maker, refundee==taker, hashlock==H, timeout, funded balance==amount) — otherwise a hostile taker deploys
claimant=selfor underfunds and the honest maker locks the asset for nothing (one-sided maker loss). We build the EXPECTED locator from the maker’s own config (NOT a taker-supplied one) and runEthHtlcContractLeg.verify_funded()against the contract atcontract_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; seeSwapCoordinator.post_asset_lock_revalidate().
- class pyrxd.EvmChain[source]
Bases:
objectOne EVM-equivalent counter chain the ETH leg machinery can run against.
chain_idpins 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 durableEthHtlcLocatorrecords it.networkis the tagEthLeg(network=...)reads for the value-bearing/audit gates.finalization_window_sseedsMarginPolicy.eth_finalization_window_s.- __init__(name, chain_id, network, finalization_window_s)
- name: str
- chain_id: int
- network: str
- finalization_window_s: int
- class pyrxd.FundingInput[source]
Bases:
objectA taker-owned UTXO used to fund the maker’s receive + fee (and/or to pay an FT the maker wants).
source_txis the taker’s own previous transaction, so its value/script are trusted (the taker controls it).keysigns it.- __init__(source_tx, vout, key)
- Parameters:
source_tx (Transaction)
vout (int)
key (PrivateKey)
- Return type:
None
- source_tx: Transaction
- vout: int
- key: PrivateKey
- class pyrxd.GlyphBuilder[source]
Bases:
objectBuild unsigned Glyph transactions.
Separate commit and reveal methods — caller is responsible for:
Signing the commit tx and broadcasting it.
Waiting for confirmation.
Passing the confirmed commit txid to the reveal method.
Signing the reveal tx (via
Transaction+PrivateKey).
Method selection guide (N9 — surface grew to 12 methods across 5 protocols)¶
Minting (commit → reveal)
Goal
Protocol tag(s)
Reveal method
Mint a singleton NFT
[NFT]prepare_reveal()Mint a plain FT
[FT]prepare_ft_deploy_reveal()Mint a dMint FT
[FT, DMINT]prepare_dmint_deploy()(3 txs)Mint a mutable NFT
[NFT, MUT]prepare_mutable_reveal()Mint a collection
``[NFT,CONTAINER]`
prepare_container_reveal()Mint a WAVE name
[NFT,MUT,WAVE]prepare_wave_reveal()For every token type the first step is the same: call
prepare_commit()(which derives the commit script from the metadata protocol list automatically). Only the reveal step differs.Transfers (no commit needed)
NFT transfer:
build_nft_transfer_tx()FT transfer:
build_ft_transfer_tx()(orFtUtxoSetinglyph/ft.py)
Low-level (rarely called directly)
prepare_reveal()— generic reveal;is_nftpicks singleton vs FT reftypebuild_reveal_scripts()— alternate reveal entry that returns scripts, not paramsbuild_transfer_locking_script()— bare FT lock without constructing a txbuild_contract_script()— MUT contract script for mutable NFT reveals
- build_ft_transfer_tx(params)[source]
Build a signed FT transfer transaction enforcing conservation.
Thin delegator to
FtUtxoSet.build_transfer_tx()— the real logic (selection, two-pass fee, conservation) lives there so the API surface is available both at the builder level and directly on a UTXO-set instance.- Parameters:
params (FtTransferParams) –
FtTransferParams— see dataclass docstring.- Returns:
FtTransferResult— signed tx + scripts + fee.- Raises:
ValueError – same conditions as
FtUtxoSet.build_transfer_tx()(insufficient FT balance, insufficient RXD for fee + dust).- Return type:
- build_nft_transfer_tx(params)[source]
Build a signed NFT transfer transaction.
Spends an existing NFT UTXO (standard P2PKH scriptSig unlock: <sig> <pubkey>) and creates a new NFT output locked to
new_owner_pkh. The 36-byte ref is preserved across the transfer — it’s extracted from the input’s NFT script and written into the new output’s NFT script unchanged.Fee calculation is two-pass: build a trial tx, sign it to measure actual serialised size, then rebuild with the final value = input_value - size*fee_rate. The trial signature is discarded (reset unlocking_script = None before final sign) so the final tx carries a signature over the final outputs, not the trial ones.
- Parameters:
params (TransferParams) – TransferParams — see dataclass docstring
- Returns:
TransferResult — signed tx, new locking script, ref, fee
- Raises:
ValidationError – nft_script is not a valid 63-byte NFT script
ValueError – nft_utxo_value - fee < 546 (dust limit)
- Return type:
TransferResult
- build_transfer_locking_script(ref, new_owner_pkh, is_nft)[source]
Build the locking script for a transfer output.
- 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_OUTPUTcheck is derived frommetadata.protocol: NFT (2in protocol) produces anOP_2/SINGLETON-expecting commit; any other protocol mix (FT, dMint FT, data, etc.) produces anOP_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; seebuild_commit_locking_scriptfor the fix note.- Parameters:
params (CommitParams)
- Return type:
CommitResult
- prepare_container_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, child_ref=None)[source]
Prepare scripts for a CONTAINER reveal.
A container is an NFT with an additional
OP_PUSHINPUTREF <child_ref>prefix that links it to a child token ref. Whenchild_refisNonethe container is created empty (no child ref in locking script).Protocol field must include
GlyphProtocol.CONTAINER(7).
- prepare_dmint_deploy(params, *, allow_v2_deploy=True)[source]
Prepare a dMint token deploy.
Dispatches on the type of
params:DmintV1DeployParams→ returnsDmintV1DeployResult. V1 is the only format on Radiant mainnet today (see GLYPH at a443d9df…878b). Two-tx deploy: commit + reveal (the reveal directly createsparams.num_contractsparallel contract UTXOs).DmintV2DeployParams→ returnsDmintV2DeployResult. V2 is consensus-proven on regtest + mainnet (#219) and now deploys by default (allow_v2_deploy=True). A softUserWarningis emitted if the caller explicitly passesallow_v2_deploy=Falseso the historical opt-out path stays observable without blocking.
- Parameters:
params (DmintV1DeployParams | DmintV2DeployParams) – Either
DmintV1DeployParams(V1 deploy) orDmintV2DeployParams(V2 deploy). The deprecatedDmintFullDeployParamsis accepted (it’s a subclass ofDmintV2DeployParams) but emits aDeprecationWarningat 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:
- 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 topremine_pkh, and its outpoint becomes the permanent token ref.Caller still constructs the actual transaction. The returned
premine_amountis whatvout[0].valuemust 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, sopremine_amountis the supply in whole units.No dMint-specific logic here. The
cbor_bytesalready encode whatever protocol markers the caller chose — dMint FT ([1,4]), plain FT ([1]), or any other combination — viaGlyphMetadata. pyrxd treats the protocol markers as caller-owned; classification happens at the indexer layer.
- prepare_mutable_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh)[source]
Prepare scripts for a MUT (mutable NFT) reveal.
Returns the two output locking scripts the caller must place in the reveal tx: -
nft_script: 63-byte NFT singleton (token the owner holds) -contract_script: 174-byte mutable contract UTXO (holds state)The reveal scriptSig suffix is also returned; the caller prepends
<sig> <pubkey>to form the full scriptSig.Protocol field in
cbor_bytesmust includeGlyphProtocol.MUT(5). UseGlyphMetadata(protocol=[GlyphProtocol.NFT, GlyphProtocol.MUT]).- Parameters:
- Return type:
- prepare_reveal(params)[source]
Prepare the reveal transaction scripts.
Returns locking script + scriptSig suffix. Caller must build, sign, and broadcast the actual transaction.
- Parameters:
params (RevealParams)
- Return type:
RevealScripts
- prepare_wave_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, name)[source]
Prepare scripts for a WAVE (on-chain naming) reveal.
WAVE extends MUT with a
namefield in the CBOR payload. Protocol field must includeGlyphProtocol.WAVE(11).namemust be non-empty printable ASCII, max 255 characters. The name is validated here but must already be embedded incbor_bytesby the caller via eitherattrs["name"](the Photonic-compatible canonical shape — required for resolution against RXinDexer and other indexers) or top-levelname(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()(orpyrxd.glyph.wave.build_wave_metadata()) to construct the canonical shape; passing a top-levelnamefield still works but emits a token RXinDexer will not index.Protocol requirement:
[NFT(2), MUT(5), WAVE(11)].
- class pyrxd.GlyphInspector[source]
Bases:
objectParse raw transaction bytes to find Glyph outputs. Pure — no network access.
- extract_reveal_metadata(scriptsig)[source]
Parse a reveal TX scriptSig to extract CBOR metadata.
scriptSig format:
<sig> <pubkey> <"gly"> <CBOR>. ReturnsNoneif this is not a reveal scriptSig (or if the CBOR is malformed / unrecognised).Catches
Exceptionbroadly 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 fromValidationErrortocbor2.CBORDecodeErrortoIndexErroron truncated input. ReturningNoneis the contract callers expect.- Parameters:
scriptsig (bytes)
- Return type:
GlyphMetadata | None
- find_glyphs(tx_outputs)[source]
Given list of (satoshis, script_bytes) outputs, return detected Glyphs.
Detects NFT singletons, FT locks, mutable NFTs, and dMint contract outputs. Plain P2PKH and unrecognised scripts are silently skipped. Commit-output classification lives outside
find_glyphsbecause a commit has no meaningfulrefuntil its reveal lands.
- 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 aglymarker followed by parseable CBOR;Noneif no input does. Distinct fromextract_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:
- 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.inputHash—SHA256d(funding_input_locking_script). NOT a preimage half; the on-chain covenant recomputesSHA256(inputHash || outputHash)from these literal pushes.outputHash—SHA256d(OP_RETURN_msg_script at vout[2]).OP_0— the sentinel push the V1/V2 covenant requires.
Verified against mainnet V1 mint
146a4d68…f3cand the V1 mintc9fdcd34…e530.Returns a dict with
nonce_hex,input_hash,output_hash,version_hint("v1"|"v2"|None), andscriptsig_length— orNoneif the scriptSig doesn’t match the canonical 4-push shape.Catches
Exceptionbroadly 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.) returnNone.
- class pyrxd.GlyphMetadata[source]
Bases:
objectCBOR payload for a Glyph token.
- __init__(protocol, name='', ticker='', description='', token_type='', main=None, attrs=<factory>, loc='', loc_hash='', decimals=0, image_url='', image_ipfs='', image_sha256='', v=None, dmint_params=None, creator=None, royalty=None, policy=None, rights=None, created='', commit_outpoint='')
- Parameters:
name (str)
ticker (str)
description (str)
token_type (str)
main (GlyphMedia | None)
loc (str)
loc_hash (str)
decimals (int)
image_url (str)
image_ipfs (str)
image_sha256 (str)
v (int | None)
dmint_params (DmintCborPayload | None)
creator (GlyphCreator | None)
royalty (GlyphRoyalty | None)
policy (GlyphPolicy | None)
rights (GlyphRights | None)
created (str)
commit_outpoint (str)
- Return type:
None
- commit_outpoint: str = ''
- created: str = ''
- creator: GlyphCreator | None = None
- decimals: int = 0
- description: str = ''
- dmint_params: DmintCborPayload | None = None
- classmethod for_dmint_ft(ticker, name, decimals=0, description='', image_url='', image_ipfs='', image_sha256='', protocol=None, dmint_params=None)[source]
Construct GlyphMetadata for a dMint-marked FT deploy.
Pass
dmint_params(aDmintCborPayload) 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=2automatically whendmint_paramsis provided.
- image_ipfs: str = ''
- image_sha256: str = ''
- image_url: str = ''
- loc: str = ''
- loc_hash: str = ''
- main: GlyphMedia | None = None
- name: str = ''
- policy: GlyphPolicy | None = None
- rights: GlyphRights | None = None
- royalty: GlyphRoyalty | None = None
- ticker: str = ''
- to_cbor_dict()[source]
Build the dict that gets CBOR-encoded (excluding ‘gly’ marker).
- Return type:
- token_type: str = ''
- class pyrxd.GlyphProtocol[source]
Bases:
IntEnum- __new__(value)
- FT = 1
- NFT = 2
- DAT = 3
- DMINT = 4
- MUT = 5
- BURN = 6
- CONTAINER = 7
- ENCRYPTED = 8
- TIMELOCK = 9
- AUTHORITY = 10
- WAVE = 11
- class pyrxd.GlyphRef[source]
Bases:
object36-byte Glyph reference: txid (reversed LE) + vout (4-byte LE).
- classmethod from_bytes(data)[source]
Parse 36-byte wire format.
- 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
00000004decodes to4: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, usefrom_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.
- txid: Txid
- vout: int
- class pyrxd.GlyphScanner[source]
Bases:
objectScan a Radiant address or script_hash for Glyph outputs.
- Parameters:
client – An already-connected ElectrumXClient. The scanner does not own the connection lifecycle; callers should use the client as a context manager and pass it in.
- __init__(client)[source]
- Parameters:
client (ElectrumXClient)
- Return type:
None
- async scan_address(address)[source]
Return all Glyph outputs currently owned at address.
- async scan_script_hash(script_hash)[source]
Return all Glyph outputs for script_hash.
Fetches UTXOs, raw transactions, and (where available) reveal transaction metadata, then constructs typed GlyphNft / GlyphFt objects.
Concurrency: UTXO raw-tx fetches and reveal-metadata fetches both run in parallel via
asyncio.gather. Pre-fix (closes ultrareview re-review N17) the reveal-metadata path was inside the per-utxo loop and serialised one round-trip per glyph; for a 100-glyph wallet that meant ~100x the latency of the now- batched version.
- class pyrxd.GravityMakerSession[source]
Bases:
objectManage the full lifecycle of a Gravity BTC↔RXD atomic swap offer.
This class handles the Maker’s side of the swap:
Build and broadcast the MakerOffer tx (
create_offer).Poll for the Taker’s claim (
wait_for_claim).Broadcast a cancel tx if the Taker never claims (
cancel_offer).Query current state (
check_status).
- Parameters:
rxd_client – Connected
ElectrumXClientfor Radiant chain operations (broadcast, query UTXOs).btc_source – A
BtcDataSource— used only by subclasses / extensions that need BTC confirmation data. May beNonefor pure Radiant operations.maker_priv – Maker’s secp256k1 private key wrapped in
PrivateKeyMaterial.poll_interval_seconds – Seconds between UTXO polls in
wait_for_claim. Default 30.
Examples
Typical Maker flow:
async with ElectrumXClient(["wss://electrumx.example.com"]) as rxd: session = GravityMakerSession(rxd_client=rxd, maker_priv=priv) params = GravityOfferParams( offer=offer, funding_txid="...", funding_vout=0, funding_photons=5_100_000, fee_sats=100_000, ) active = await session.create_offer(params) claim_txid = await session.wait_for_claim(active, timeout_seconds=3600) if claim_txid is None: cancel_txid = await session.cancel_offer(active)
- __init__(rxd_client, maker_priv, btc_source=None, poll_interval_seconds=30)[source]
- Parameters:
rxd_client (ElectrumXClient)
maker_priv (PrivateKeyMaterial)
btc_source (BtcDataSource | None)
poll_interval_seconds (int)
- Return type:
None
- async cancel_offer(offer, fee_sats=1000, maker_address='')[source]
Broadcast the cancel (MakerOffer.cancel()) transaction.
Reclaims the MakerOffer UTXO before the claim deadline using
build_cancel_tx. This is only valid if the Taker has NOT yet claimed the UTXO.- Parameters:
offer (ActiveOffer) – The
ActiveOfferto cancel.fee_sats (int) – Miner fee in photons for the cancel tx. Default 1000.
maker_address (str) – Maker’s Radiant P2PKH address to receive the reclaimed photons. Required — must be a valid Radiant address.
- Returns:
The cancel tx’s txid.
- Return type:
- Raises:
ValidationError – If
maker_addressis empty or the offer redeem is invalid.NetworkError – On broadcast failure.
- 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
ActiveOfferto check.- Returns:
One of
"open","claimed","expired","unknown".- Return type:
- 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 withbuild_claim_tx.- Parameters:
offer_params (GravityOfferParams) – Funding-UTXO details and the
GravityOffercovenant.- Returns:
Populated with the resulting txid and UTXO details.
- Return type:
- Raises:
ValidationError – On any parameter format or covenant validation error.
NetworkError – On broadcast failure.
- async wait_for_claim(offer, timeout_seconds=3600)[source]
Poll for the Taker’s claim transaction.
Polls
get_utxos()on the MakerOffer P2SH script hash. When the UTXO disappears from the unspent set the Taker has claimed it.This method cannot directly return the claim txid — ElectrumX’s
listunspentAPI 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. viaget_transactionon the address history).- Parameters:
offer (ActiveOffer) – The
ActiveOfferreturned bycreate_offer.timeout_seconds (int) – Maximum seconds to wait. Returns
Noneon timeout.
- Returns:
The offer txid (as a claimed-sentinel) on success, or
Noneon timeout.- Return type:
str or None
- class pyrxd.GravityOfferParams[source]
Bases:
objectParameters required to create a new Gravity MakerOffer.
These are the funding-UTXO details for the Maker’s side. The
GravityOfferitself (covenant bytecode, BTC-side params, etc.) is built externally (e.g. viabuild_gravity_offer) and passed asoffer.- offer
Fully populated
GravityOfferwithoffer_redeem_hexset.
- funding_txid
Hex txid of the Maker’s P2PKH UTXO being spent to fund the offer.
- Type:
- funding_vout
Output index of the Maker’s funding UTXO.
- Type:
- funding_photons
Value of the Maker’s funding UTXO in photons.
- Type:
- fee_sats
Miner fee in photons for the MakerOffer funding tx.
- Type:
- change_address
Optional Radiant P2PKH address for change output. See
build_maker_offer_txfor semantics.- Type:
str | None
- __init__(offer, funding_txid, funding_vout, funding_photons, fee_sats, change_address=None)
- offer: GravityOffer
- funding_txid: str
- funding_vout: int
- funding_photons: int
- fee_sats: int
- class pyrxd.GravityTrade[source]
Bases:
objectOrchestrate a complete Gravity BTC↔RXD atomic swap.
- Parameters:
radiant_network – Connected
ElectrumXClientfor Radiant chain operations (broadcast, fetch tx/block).bitcoin_source – A
BtcDataSourcefor Bitcoin chain data (tx fetch, Merkle proof, block headers).config – Optional
TradeConfig. Uses defaults if not provided.
Examples
Typical Taker flow:
async with ElectrumXClient(["wss://electrumx.example.com"]) as rxd: trade = GravityTrade(radiant_network=rxd, bitcoin_source=btc_src) claim = await trade.claim( offer=offer, offer_txid="...", offer_vout=0, offer_photons=10_000_000, fee_sats=1000, taker_privkey=privkey, ) btc_txid = "..." # broadcast BTC payment externally status = await trade.wait_confirmations(btc_txid) result = await trade.finalize( btc_txid=btc_txid, offer=offer, claimed_txid=claim.txid, claimed_vout=0, claimed_photons=claim.output_photons, taker_address="...", fee_sats=1000, )
- __init__(*, radiant_network, bitcoin_source, config=None)[source]
- Parameters:
radiant_network (ElectrumXClient)
bitcoin_source (BtcDataSource)
config (TradeConfig | None)
- Return type:
None
- async claim(offer, offer_txid, offer_vout, offer_photons, fee_sats, taker_privkey)[source]
Spend the MakerOffer UTXO, creating a MakerClaimed UTXO.
Broadcasts the claim transaction to the Radiant network and returns a
ClaimResult.The claim transaction requires Taker’s signature (audit 04-S3).
build_claim_txindependently verifies the code hash before signing (audit 05-F-13).- Parameters:
offer (GravityOffer) – The
GravityOfferposted by the Maker.offer_txid (str) – Radiant txid of the MakerOffer funding output.
offer_vout (int) – Output index of the MakerOffer UTXO.
offer_photons (int) – Value of the MakerOffer UTXO in photons.
fee_sats (int) – Radiant miner fee in photons.
taker_privkey (PrivateKeyMaterial) – Taker’s secp256k1 private key.
- Return type:
- async finalize(btc_txid, offer, claimed_txid, claimed_vout, claimed_photons, taker_address, fee_sats, btc_tx_height=None)[source]
Fetch the BTC SPV proof, verify it, and broadcast the finalize tx.
This method always runs the full
SpvProofBuilderverifier 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
GravityOfferoriginally posted by the Maker. Used to constructCovenantParamsfor SPV proof verification.claimed_txid (str) – Radiant txid of the MakerClaimed UTXO (output of
claim()).claimed_vout (int) – Output index of the MakerClaimed UTXO.
claimed_photons (int) – Value of the MakerClaimed UTXO in photons.
taker_address (str) – Taker’s Radiant P2PKH address to receive the photons.
fee_sats (int) – Radiant miner fee in photons.
btc_tx_height (int | None) – Optional: Bitcoin block height where btc_txid was confirmed. If not provided, the orchestrator will determine it automatically.
- Raises:
SpvVerificationError – If any SPV verifier rejects the proof.
NetworkError – On any network failure fetching BTC data.
ValidationError – On any parameter format error.
- Return type:
- async wait_confirmations(btc_txid, min_confirmations=None)[source]
Poll Bitcoin until btc_txid reaches the required confirmations.
- Parameters:
- Returns:
Always has
confirmed=Trueon return (raises on timeout).- Return type:
- Raises:
NetworkError – If polling exceeds
config.max_poll_attempts.ValidationError – If btc_txid is not a valid 64-char hex string.
- class pyrxd.HdWallet[source]
Bases:
objectBIP44 HD wallet for Radiant with gap-limit discovery and encrypted persistence.
- account
BIP44 account index (usually 0).
- Type:
- coin_type
BIP44 coin type (read-only property; back-store
_coin_typeis 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_xprvand 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:
- internal_tip
Highest derived index on internal chain (change=1).
- Type:
- addresses
{path_key: AddressRecord}where path_key isf"{change}/{index}".- Type:
- __init__(_seed, account=0, _coin_type=<factory>, external_tip=0, internal_tip=0, addresses=<factory>)
- Parameters:
_seed (SecretBytes)
account (int)
_coin_type (int)
external_tip (int)
internal_tip (int)
addresses (dict[str, AddressRecord])
- Return type:
None
- account: int = 0
- build_send_max_tx(triples, to_address, *, fee_rate=10000)[source]
Sweep all triples to to_address minus fee. No change output.
- build_send_tx(triples, to_address, photons, *, fee_rate=10000, change_address=None)[source]
Build and sign a P2PKH transfer from HD UTXOs to to_address.
Pure offline operation. Mirrors
RxdWallet.build_send_tx()but accepts (utxo, address, privkey) triples so each input is signed by the correct HD-derived key.change_addressdefaults to the next unused internal index; callers can override (e.g. to keep change on the external chain for a single-address-style wallet).
- 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 blockswallet._coin_type = X; the property blockswallet.coin_type = X.
- async collect_spendable(client)[source]
Return
(utxo, address, privkey)triples for every UTXO across known addresses.Address→key mapping is preserved so signing works correctly per UTXO. Falls back gracefully if any per-address fetch fails (the failed address contributes nothing rather than crashing the whole collection — the caller decides whether the resulting balance is enough).
- Parameters:
client (ElectrumXClient)
- Return type:
- derive_address(change, index)[source]
Derive the P2PKH address at
change/index(public seam).
- external_tip: int = 0
- classmethod from_mnemonic(mnemonic, passphrase='', account=0, coin_type=None)[source]
Create a fresh wallet from a BIP39 mnemonic.
- coin_type selects the BIP44 derivation path:
None(default) uses the module-level configured coin type (env varRXD_PY_SDK_BIP44_DERIVATION_PATH, or SLIP-0044’s 512 if unset).512is SLIP-0044 Radiant (also Tangem).0matches Photonic and Electron-Radiant — pass this when restoring a mnemonic from those wallets.236matches pre-#14 pyrxd wallets.
The chosen coin type is recorded on the wallet and persisted in the wallet file; subsequent
load()calls validate it.
- async get_balance(client)[source]
Return total confirmed + unconfirmed satoshis across all known addresses.
Uses
ElectrumXClient.get_balanceper address. Callrefresh()first to ensure the address set is current.- Parameters:
client (ElectrumXClient)
- Return type:
- async get_utxos(client)[source]
Return all UTXOs across all known addresses.
- Parameters:
client (ElectrumXClient)
- Return type:
list[UtxoRecord]
- internal_tip: int = 0
- known_addresses(*, change=None)[source]
Return all known address records, optionally filtered by chain.
- Parameters:
change (int | None)
- Return type:
- classmethod load(path, mnemonic, passphrase='', coin_type=None)[source]
Load a previously saved wallet from path.
The mnemonic is needed to derive the decryption key. Raises
FileNotFoundErrorif 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 useload_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. PassNone(default) to accept whatever was persisted.
- classmethod load_or_create(path, mnemonic, passphrase='', account=0, coin_type=None)[source]
Load a wallet from path, or build a fresh one if the file is missing.
Spelled separately from
load()so the create-on-missing intent is explicit at the call site. A common safety failure with the old single-load API was that a typo in path would produce an empty wallet that subsequently overwrote the real wallet on save.coin_type applies to both branches: when loading, it is validated against the persisted value; when creating, it is the coin type the new wallet uses.
- next_receive_address()[source]
Return the first external (change=0) address with no recorded history.
- Return type:
- privkey_for(change, index)[source]
Derive the signing key at
change/index(public seam over_privkey_for).
- 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:
- save(path)[source]
Encrypt and atomically save wallet state to path.
Atomicity & permissions¶
- Writes via mkstemp + fchmod(0o600) + fsync + os.replace, so:
The file is never visible at a wider mode than 0o600 — the mode is set on the fd before any bytes are written.
A crash mid-write cannot leave a half-encrypted blob in place — either the old file remains, or the new fully-fsynced file does.
Encryption¶
AES-256-GCM under a key derived from the BIP39 seed via scrypt with a per-file random salt. Tampering with the ciphertext breaks the GCM tag —
load()raises rather than returning attacker-shaped JSON.- Parameters:
path (Path)
- Return type:
None
- async send(client, to_address, photons, *, fee_rate=10000, change_address=None)[source]
Fetch UTXOs, build, sign, broadcast. Returns broadcast txid.
Raises
ValidationErroron bad inputs or insufficient funds,NetworkErroron RPC failure.
- async send_max(client, to_address, *, fee_rate=10000)[source]
Sweep all UTXOs to to_address minus fee. Returns broadcast txid.
- Parameters:
client (ElectrumXClient)
to_address (str)
fee_rate (int)
- Return type:
- 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
_xprvproperty re-derives it transiently from the seed per operation — so the ONLY resident long-lived secret is this 64-byte seed, which lives in aSecretBytesand IS memset here. Setting_zeroed(matchingSecretBytes._zeroed) makes the_xprvproperty 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:
objectA built HTLC covenant: the funded SPK + the bindings a spend must satisfy.
- variant
“ft” | “nft” | “rxd”.
- Type:
- funded_spk
The scriptPubKey of the covenant UTXO the maker locks the asset into.
- Type:
- 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:
- taker_holder_script / maker_holder_script
The holder scripts
output[0]of a claim (taker) / refund (maker) must equal; the covenant bindshash256of 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:
- hashlock
The 32-byte
H = SHA256(p).- Type:
- refund_csv
The relative-timelock block count for the refund branch.
- Type:
- __init__(variant, funded_spk, prologue_len, taker_holder_script, maker_holder_script, expected_taker_hash, expected_maker_hash, genesis_ref, hashlock, refund_csv)
- variant: str
- funded_spk: bytes
- prologue_len: int
- taker_holder_script: bytes
- maker_holder_script: bytes
- expected_taker_hash: bytes
- expected_maker_hash: bytes
- genesis_ref: bytes
- hashlock: bytes
- refund_csv: int
- class pyrxd.MarginPolicy[source]
Bases:
objectHow the cross-chain timelock margin is computed and enforced.
- margin
The required minimum
t_btc - t_rxd, as a unit-taggedTimelock. Ifis_measuredis False this is an ESTIMATE.
- 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:
- is_measured
True only when
margin+block_interval_swere derived from real block data (both chains) + a stated reorg depth. Estimates are test-only.- Type:
- 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:
- __init__(margin, block_interval_s, is_measured, require_measured=False, rxd_block_interval_s=300.0, btc_claim_reorg_depth=<factory>, rxd_claim_burial=<factory>, rxd_reorg_cost_per_block=None, value_at_risk_photons=None, burial_safety_factor=1.0, accept_flat_burial=False, eth_finalization_window_s=None, cross_clock_margin=None, max_covenant_confirm_wait_s=None)
- Parameters:
margin (Timelock)
block_interval_s (float)
is_measured (bool)
require_measured (bool)
rxd_block_interval_s (float)
btc_claim_reorg_depth (Timelock)
rxd_claim_burial (Timelock)
rxd_reorg_cost_per_block (int | None)
value_at_risk_photons (int | None)
burial_safety_factor (float)
accept_flat_burial (bool)
eth_finalization_window_s (int | None)
cross_clock_margin (CrossClockMargin | None)
max_covenant_confirm_wait_s (int | None)
- Return type:
None
- accept_flat_burial: bool = False
- burial_safety_factor: float = 1.0
- cross_clock_margin: CrossClockMargin | None = None
- classmethod estimated(*, block_interval_s=600.0, require_measured=False, accept_flat_burial=False)[source]
The ESTIMATED, test-only policy. Refuses to construct in real-value mode.
accept_flat_burialis 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.
- classmethod measured(*, margin, block_interval_s, btc_claim_reorg_depth=None, rxd_claim_burial=None, rxd_block_interval_s=None, rxd_reorg_cost_per_block=None, value_at_risk_photons=None, burial_safety_factor=1.0, accept_flat_burial=False)[source]
A measured policy for real-value mainnet swaps.
btc_claim_reorg_depth/rxd_claim_burialare the reorg gate’s measured inputs; if omitted they fall back to the ESTIMATED defaults (acceptable only because a measured policy still carries the estimated reorg depths — supply measured values for a real mainnet swap).rxd_reorg_cost_per_block(measured, photons/block) +value_at_risk_photons(the assessed economic value) drive the VALUE-SCALED claim burial (red-team HIGH): supply both for a value-bearing Radiant swap, or setaccept_flat_burial=Truefor a dust run — the coordinator refuses a value-bearing swap that leaves them unset.- Parameters:
- Return type:
MarginPolicy
- require_measured: bool = False
- rxd_block_interval_s: float = 300.0
- margin: Timelock
- block_interval_s: float
- is_measured: bool
- btc_claim_reorg_depth: Timelock
- rxd_claim_burial: Timelock
- class pyrxd.NegotiatedTerms[source]
Bases:
objectEverything the two parties agree before any lock — chain-agnostic.
Carries the hashlock ``H`` only, never the preimage
p(the maker holdspin memory asSecretBytes). ONE canonical hex wire form viato_dict()/from_dict()(JSON, never pickle).Timelocks are unit-tagged
Timelock(BIP68/112). The cross-chain ordering invariantt_btc - t_rxd >= marginis checked by the coordinator (seeswap_coordinator.assert_timelock_margin), not here — but the raw orderingt_btc > t_rxdin the same unit is rejected at construction as a cheap fail-closed guard.- __init__(hashlock, btc_sats, radiant_amount, t_btc, t_rxd, asset_variant, genesis_ref, taker_dest_hash, maker_dest_hash, btc_claim_pubkey_xonly, btc_refund_pubkey_xonly, counter_chain='btc', value_amount=0, eth_timeout_unix_s=None, credential_ref=b'')
- Parameters:
hashlock (bytes)
btc_sats (int)
radiant_amount (int)
t_btc (Timelock)
t_rxd (Timelock)
asset_variant (str)
genesis_ref (bytes)
taker_dest_hash (bytes)
maker_dest_hash (bytes)
btc_claim_pubkey_xonly (bytes)
btc_refund_pubkey_xonly (bytes)
counter_chain (str)
value_amount (int)
eth_timeout_unix_s (int | None)
credential_ref (bytes)
- Return type:
None
- counter_chain: str = 'btc'
- credential_ref: bytes = b''
- value_amount: int = 0
- hashlock: bytes
- btc_sats: int
- radiant_amount: int
- t_btc: Timelock
- t_rxd: Timelock
- asset_variant: str
- genesis_ref: bytes
- taker_dest_hash: bytes
- maker_dest_hash: bytes
- btc_claim_pubkey_xonly: bytes
- btc_refund_pubkey_xonly: bytes
- class pyrxd.PowChain[source]
Bases:
objectOne Bitcoin-family counter chain the Taproot-HTLC leg can run against.
network/testnet_network/regtest_networkare the bech32 HRPs — the tag the leg, the locator, and the audit gates all key on.block_interval_sseedsMarginPolicy(block_interval_s=...).- __init__(name, network, testnet_network, regtest_network, block_interval_s)
- 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
- address(compressed=None, network=None)[source]
- decrypt(message)[source]
Electrum ECIES (aka BIE1) decryption
- decrypt_text(text)[source]
decrypt BIE1 encrypted, base64 encoded text
- 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
- encrypt(message)[source]
Electrum ECIES (aka BIE1) encryption
- encrypt_text(text)[source]
- public_key()[source]
- Return type:
PublicKey
- sign(message, hasher=<function double_sha256>, k=None)[source]
- Returns:
ECDSA signature in bitcoin strict DER (low-s) format
- Parameters:
- Return type:
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
kbypasses RFC 6979 deterministic-nonce generation. ECDSA leaks the private key if the sameksigns two different messages under the same key. Only supplykfor an R-puzzle (seepyrxd.script.type.RPuzzle.unlock()) and only with a throwaway key that signs nothing else. LeavekasNonefor all normal signing — libsecp256k1’s deterministic nonce is the safe path.
- sign_recoverable(message, hasher=<function double_sha256>)[source]
- 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.
- verify(signature, message, hasher=<function double_sha256>)[source]
verify ECDSA signature in bitcoin strict DER (low-s) format
- 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)”
- class pyrxd.RadiantCovenantLeg[source]
Bases:
objectThe 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 inexpected_covenant_scriptpubkey()).chain_io – A
RadiantChainIO(broadcast + confirmations + UTXO value).fee_source – A
FeeUtxoSourcesupplying 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(seepyrxd.btc_wallet.htlc_leg.require_audit_cleared()).
- __init__(*, network, taker_pkh, maker_pkh, chain_io, fee_source, min_confirmations=1, audit_cleared=False)[source]
- async claim_asset(record, preimage)[source]
Build + broadcast the TAKER’s claim spend (reveals
p). Returns the txid.
- async covenant_outpoint(terms)[source]
Locate the funded covenant UTXO
txid:voutby 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_amountso a mis-funded covenant fails closed.- Parameters:
terms (NegotiatedTerms)
- Return type:
- async expected_covenant_scriptpubkey(terms)[source]
The covenant SPK the on-chain lock must equal (built from the terms).
- Parameters:
terms (NegotiatedTerms)
- Return type:
- class pyrxd.RegtestNode[source]
Bases:
objectA self-managed, isolated
radiant-coreregtest node (docker).The node is identified by a fixed container name so that
up/mine/fund/downinvoked as separate processes all operate on the same chain.upis the only call that creates the container; the others attach to the running one and raiseDevnetErrorif it is absent.- CONTAINER = 'pyrxd-devnet'
- IMAGE = 'radiant-core:v3.1.1-amd64'
- RPC_PASSWORD = 'pyrxd'
- RPC_USER = 'pyrxd'
- WALLET = 'devnet'
- classmethod build_image(version='v3.1.1', *, no_cache=False)[source]
Build the regtest image from an OFFICIAL Radiant-Core release binary.
Wraps the published
radiant-<version>-linux-x64daemon (SHA-256-verified against the release checksum file) in a small ubuntu:22.04 image taggedradiant-core:<version>-amd64. Builds from the Dockerfile embedded in this module, so it works for apip install pyrxddeveloper 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 setupcalls it.
- cli(*args, wallet=False)[source]
Run
radiant-cliinside the container; parse JSON when possible.
- fund(address, amount_rxd, *, confirm=True)[source]
Faucet: send
amount_rxdRXD toaddressfrom the dev wallet.Mines one block to confirm the payment unless
confirmis False. Returns the funding txid.
- mine(n=1, address=None)[source]
Mine
nblocks toaddress(a fresh wallet address by default).Returns the new chain height.
- 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.
- start(*, fresh=False, initial_blocks=101)[source]
Start the regtest node, create the dev wallet, and mature a coinbase.
Idempotent unless
freshis set: if the container is already running it is left untouched (the chain state is preserved).fresh=Truetears the existing container down first for a clean chain.
- stop()[source]
Remove the devnet container (no-op if absent). Wipes the chain.
- Return type:
None
- exception pyrxd.RxdSdkError[source]
Bases:
ExceptionBase class for every exception raised by pyrxd.
Applying
redactto each positional arg on construction defends against accidental key-material leakage when callers pass user-supplied values straight into the exception.
- class pyrxd.RxdWallet[source]
Bases:
objectHigh-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 withElectrumXClient([url]).fee_rate – Miner fee in photons per byte. Defaults to 10_000 (the current mainnet relay minimum).
allow_insecure – Pass-through to
ElectrumXClient. Only set for local dev.
- __init__(private_key, electrumx_url, fee_rate=10000, *, allow_insecure=False)[source]
- property address: str
Return the P2PKH mainnet address of this wallet.
- build_send_max_tx(utxos, to_address)[source]
Build and sign a tx sweeping all provided UTXOs to to_address.
No change output. Single output value =
sum(utxos) - fee.- Parameters:
- Return type:
- 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¶
photonsmust be >=DUST_THRESHOLD(546).UTXOs are greedily selected in descending order of value.
A change output back to
self.addressis added only if the remainder after paying the fee exceeds the dust threshold; otherwise the dust is burned as additional fee.
- Parameters:
- Return type:
- property fee_rate: int
- async get_balance()[source]
Return
(confirmed_photons, unconfirmed_photons)for this wallet.
- async get_utxos()[source]
Return typed
UtxoRecordlist 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
ValidationErroron bad inputs or insufficient funds,NetworkErroron RPC failure.
- class pyrxd.SoulboundNftCovenant[source]
Bases:
objectA 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:
- genesis_ref
The 36-byte wire-format singleton ref bound by the covenant.
- Type:
- 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:
- recur_target_spk
The scriptPubKey
output[0]of a (non-burn) spend MUST equal. For a soulbound covenant this is identical tofunded_spk— the self-clone.
- __init__(funded_spk, genesis_ref, owner_pkh)
- property recur_target_spk: bytes
- funded_spk: bytes
- genesis_ref: bytes
- owner_pkh: bytes
- class pyrxd.SpvProof[source]
Bases:
objectA 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 itsCovenantParamsso 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)
- txid: str
- raw_tx: bytes
- branch: bytes
- pos: int
- output_offset: int
- covenant_params: CovenantParams
- class pyrxd.SpvProofBuilder[source]
Bases:
objectBuild and verify an SPV proof against a specific covenant’s parameters.
Construction requires the full
CovenantParams(audit 05-F-2 / F-3 fix). Thebuildmethod runs every verifier and refuses to return partially verified proofs: if any check fails,SpvVerificationErroris 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:
Strip witness; stripped raw tx length > 64 (Merkle forgery defense).
hash256(stripped_raw_tx) == txid(tx integrity).PoW + chain link for every header (anchor-bound).
Merkle inclusion (with depth binding + coinbase guard).
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 - 1in the anchor-chained sequence, instead of accepting a root that matches ANY fetched header. Productionfinalize()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.Nonekeeps the weaker flexible-anchor search (tx may land in any of h1..hN).txid_be (str)
raw_tx_hex (str)
pos (int)
output_offset (int)
- Raises:
SpvVerificationError – on any failure. Never returns a partial proof.
- Return type:
- 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 usingSpvProofBuilder(covenant_params)directly.- Parameters:
covenant_params (CovenantParams)
network (str)
audit_cleared (bool)
- Return type:
- class pyrxd.SwapCoordinator[source]
Bases:
objectDrive 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 unlessconfig.accept_nondurable_seenis set.config –
CoordinatorConfig(margin policy + maker-stall window).persist – Optional
async (SwapRecord) -> Nonedurable-write hook. When supplied, the coordinator persists the intent record BEFORE an awaited broadcast andasyncio.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).Nonedisables 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]
- 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) == Hbefore broadcasting (defends a swapped/garbled secret). The maker holdsponly asSecretBytes; it is zeroized immediately after the claim is handed to the BTC leg.pzeroization infinallyruns on the cancel path too. If the awaited claim raises AFTER the tx hit the mempool,pis wiped from memory but is now public on-chain — recovery re-scrapes it from the chain, never memory.- Parameters:
preimage (SecretBytes)
- Return type:
SwapRecord
- async maker_verify_counter_funding(counter_contract_address)[source]
MAKER-side fail-closed gate (red-team CRITICAL fix): the maker MUST verify the TAKER-deployed counter-leg HTLC binds to the negotiated terms + the maker’s own payout config BEFORE the maker locks the asset (funds the RXD covenant). Returns on success (recording the verified locator on the record so
maker_claims_btc()can claim it); RAISES on any mismatch — the maker MUST NOT lock the asset if this raises.WHY THIS EXISTS: the runbook is TAKER-funds-counter-FIRST, MAKER-locks-asset-SECOND. For a BTC counter leg the funding target is a pure function of terms, so the coordinator’s
derive==promisedpre-fund gate + the funding reader already bind it. For an ETH counter leg there is NO pre-fund commitment — the contract does not exist until the taker deploys it — soEthHtlcContractLeg.verify_fundedis the ONLY thing binding the taker’s contract to terms, and it previously ran ONLY inside the taker’s ownfund(). Without this maker-side call a hostile taker deploysclaimant=self(or underfunds / sets a bad timeout) and the honest maker locks the asset for nothing — a one-sided maker loss reachable in the intended two-party flow. The maker passes ONLY the contract ADDRESS (the one untrusted input from the taker); the leg builds the EXPECTED locator from the maker’s own config and verifies the chain matches it.counter_contract_addressis the address the taker advertises for its deployed HTLC.- Parameters:
counter_contract_address (str)
- Return type:
SwapRecord
- async maybe_refund_asset_on_maker_stall(*, now_block_height, asset_locked_at_height, maker_has_claimed_btc)[source]
If the maker is stalling near
t_RXD - N, refund the asset proactively.Drives BOTH_LOCKED -> MAKER_STALLS -> ASSET_REFUNDED_TAKER_ACTS. A no-op (returns the unchanged record) when the trigger has not fired yet. Async because the asset refund broadcasts a Radiant covenant spend.
RUNBOOK SCOPE (FSM finding #2, 2026-06-09 — VERIFIED on regtest): this refunds ONLY the RXD covenant, whose CSV refund pays the MAKER in BOTH directions (the maker owns the asset leg; p is not yet public) — it is NOT a “taker reclaims the covenant” action (an earlier note wrongly said the taker owns it; the covenant CLAIM pays the taker, the CSV REFUND pays the maker, same as eth_rxd_timelock.py).
This is a MAKER-side primitive (the maker recovering its own asset) and MUST NOT be wired into a TAKER recovery path on EITHER counter-chain. A taker driven to run it strands itself: it gifts the asset back to the maker AND destroys its only recourse (the claimable covenant) while its own counter-leg stays locked, after which the maker — still holding p — claims the counter-leg and takes both (proven by tests/test_xchain_swap_regtest_e2e.py:: TestMakerStallAssetOnlyRefundIsTakerLoss). The correct TAKER stall recovery on BOTH the BTC and ETH runbooks is
mutual_refund()(refunds BOTH legs after both timeouts). The watchtower (gravity.watch.decide) routes neither counter-chain’s taker here.
- 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_sis the caller’s wall-clock at the moment the covenant lock is observed — REQUIRED for an ETH swap (the post-confirm cross-clock recheck against a stalled maker lock; audit re-verify HIGH), ignored for BTC. On an ETH timing failure this refuses to advance to BOTH_LOCKED (raises) so the taker refunds the counter leg.Async because the Radiant leg reads chain state (expected-SPK derivation + covenant outpoint lookup) over the async indexer/node.
- 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):
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.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.The cross-chain timelock ordering. BTC: the same-clock margin
t_btc - t_rxd >= margin. ETH: the cross-clock gate that validates the ABSOLUTEeth_timeout_unix_sleaves room for the RELATIVEt_rxdwindow (needsnow_unix_s; audit HIGH-1). The orphaned bridge is wired here.Maker-promised params match the locally re-derived BTC funding SPK (the on-chain re-validation happens later in
post_asset_lock_revalidate()).
now_unix_sis the caller’s wall-clock (thenow_rxd_heightprecedent: the coordinator takes clocks as params, never reads them) — REQUIRED for an ETH swap, ignored for BTC. Async because binding (1) awaits the async indexer adapter (a sync gate would leak a truthy un-awaited coroutine = fail-OPEN, T7 plan D2).- Parameters:
terms (NegotiatedTerms)
now_unix_s (int | None)
- Return type:
PreBtcLockGate
- async taker_claim_asset_from_vulnerable(maker_claim_tx_bytes)[source]
Best-effort asset claim from ASSET_VULNERABLE — an EXPLICIT policy decision.
Only valid from ASSET_VULNERABLE (reached when the reorg gate found the swap SQUEEZED). This is winner-take-all: the taker races to claim the asset before the maker’s
t_rxdCSV 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_bytescarries the maker’s ETH claim tx hash; the scrape + provenance gate dispatch to the ETH path. The BTC body below is byte-for-byte unchanged.- Parameters:
maker_claim_tx_bytes (bytes)
- Return type:
SwapRecord
- async taker_funds_btc(terms, *, now_unix_s=None)[source]
Run the pre-lock gate, fund the counter-leg HTLC, record the locator, advance.
Refuses (raises) if the pre-lock gate fails — the taker NEVER funds against a failed gate. H is ATOMICALLY reserved in the seen-store PRE-broadcast (so a concurrent or repeat funder of the same H is refused before any value moves; TOCTOU-1), and the durable record carries the full counter-leg locator.
now_unix_sis 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.fundbroadcasts 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, andasyncio.shield()the post-broadcast persist of the funded record.funditself 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 nopersisthook is injected.- Parameters:
terms (NegotiatedTerms)
now_unix_s (int | None)
- Return type:
SwapRecord
- async taker_refund_btc()[source]
Refund the BTC via the timelock leg, ending in ABORTED.
Valid from BTC_LOCKED (maker never locked, t_btc elapsed) or PARAMS_MISMATCH (maker locked the wrong covenant). The refund needs the FULL locator (Tapscript tree + control block) — recovered from the durable record. Async because the refund broadcasts the BTC timelock spend.
- Return type:
SwapRecord
- async taker_scrape_and_claim_asset(maker_claim_tx_bytes, *, now_rxd_height, asset_locked_at_height)[source]
Scrape
pand claim the asset — gated on the maker’s BTC-claim finality.Scraping is by
sha256(candidate) == Hover the witness pushes (never by offset); the coordinator RE-verifiessha256(p) == Hfirst — 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
pis public reintroduces one-sided loss. Before firing the Radiant claim we read the maker’s BTC-claim confirmation depth and run thet_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_rxdwindow is closing: advance to ASSET_VULNERABLE (logged loudly) and STOP. The caller’s policy then decides a best-effort winner-take-all claim viataker_claim_asset_from_vulnerable()vs abandoning — never a silent claim off a shallow reveal.
now_rxd_height/asset_locked_at_heightfeed the squeeze (the Radiant clock;asset_locked_at_heightis where the maker locked the covenant).scrape_secretis 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, scrapesp, 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.
- class pyrxd.SwapOffer[source]
Bases:
objectA maker’s signed partial transaction plus everything a taker needs to verify it.
Transport-agnostic.
partial_tx_hexholds the maker’s input (signedSINGLE|ANYONECANPAY) and output[0] (what the maker wants to receive).give_source_tx_hexis 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 declaredterms— and confirm it hashes to the input’s outpoint.- __init__(partial_tx_hex, give_source_tx_hex, give_vout, terms)
- partial_tx_hex: str
- give_source_tx_hex: str
- give_vout: int
- terms: SwapTerms
- class pyrxd.SwapRecord[source]
Bases:
objectThe durable, crash-recoverable state of one in-flight swap.
Persisted from the FIRST lock onward (a crash that loses the
BtcHtlcLocatorstrands the BTC — the refund needs the whole Tapscript tree + control block). Round-trips to/from JSON via hex;pis excluded by construction (the maker holds it in memory asSecretBytes, the taker re-scrapes it from chain).Optional on-chain handles (filled in as locks land): *
counterchain_locator— the funded counter-leg HTLC, aBtcHtlcLocator(BTC swap) or
EthHtlcLocator(ETH swap), after the counter-leg lock. Thebtc_locatorproperty is a transitional BTC-only alias for it.radiant_covenant_outpoint— “txid:vout” of the funded Radiant covenant (after BOTH_LOCKED).radiant_covenant_spk_hex— the observed on-chain covenant scriptPubKey, used by the post-asset-lock revalidation gate.
- __init__(state, terms, counterchain_locator=None, radiant_covenant_outpoint=None, radiant_covenant_spk_hex=None)
- Parameters:
state (SwapState)
terms (NegotiatedTerms)
counterchain_locator (BtcHtlcLocator | EthHtlcLocator | None)
radiant_covenant_outpoint (str | None)
radiant_covenant_spk_hex (str | None)
- Return type:
None
- property btc_locator: BtcHtlcLocator | None
Transitional BTC-only alias for
counterchain_locator— returns it iff it is aBtcHtlcLocator(else None). Lets BTC reader sites keep using.btc_locatoruntil they migrate to the chain-neutralcounterchain_locator.
- counterchain_locator: BtcHtlcLocator | EthHtlcLocator | None = None
- to_dict()[source]
JSON-serialisable form. The preimage
pis 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, noschema_version), byte-for-byte identical to the pre-ETH schema; a swap whose counter-leg locator is anEthHtlcLocatorserialises the v2 chain-taggedcounterchain_locator+schema_version.- Return type:
- with_btc_lock(locator)[source]
Transitional alias for
with_counter_lock()(BTC reader sites).- Parameters:
locator (BtcHtlcLocator)
- Return type:
SwapRecord
- with_counter_lock(locator)[source]
Attach the funded counter-leg locator (BTC or ETH).
- Parameters:
locator (BtcHtlcLocator | EthHtlcLocator)
- Return type:
SwapRecord
- with_radiant_lock(outpoint, spk_hex)[source]
- with_state(state)[source]
Return a copy advanced to
state(transition not re-validated here; the coordinator validates viaadvance()before persisting).- Parameters:
state (SwapState)
- Return type:
SwapRecord
- state: SwapState
- terms: NegotiatedTerms
- class pyrxd.SwapState[source]
Bases:
EnumThe 13 states of the atomic-swap safety machine.
Terminal states (the diagram’s
--> [*]) are enumerated inTERMINAL_STATES. Every non-terminal state has at least one defined exit (enforced bytest_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:
objectThe trade as the maker states it: maker gives
give, receivesreceive.From the taker’s seat this reads in reverse — the taker receives
giveand paysreceive. The terms are a human-readable cross-check; the maker’s signature on the partial tx is what actually enforces them (seepyrxd.swap.partial.accept_offer()).- give: Asset
- receive: Asset
- class pyrxd.UtxoRecord[source]
Bases:
objectA single unspent transaction output as returned by ElectrumX.
- tx_hash
Transaction id in hex (little-endian / display order).
- Type:
- tx_pos
Output index within the transaction.
- Type:
- value
Output value in satoshis.
- Type:
- height
Block height at which the output was confirmed (0 = unconfirmed).
- Type:
- __init__(tx_hash, tx_pos, value, height)
- tx_hash: str
- tx_pos: int
- value: int
- height: int
- exception pyrxd.ValidationError[source]
Bases:
RxdSdkErrorRaised when input fails a trust-boundary validation check.
- class pyrxd.Xprv[source]
Bases:
Xkey- classmethod from_seed(seed, network=Network.MAINNET)[source]
derive master extended private key from seed
- 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:
- pyrxd.accept_offer(offer, *, funding, taker_receive_pkh, taker_change_pkh, fee)[source]
Complete and sign a maker’s offer, returning a broadcast-ready transaction.
Safety, by construction:
The maker’s given asset is read from
offer.give_source_tx_hex(verified to hash to the maker input’s outpoint) — never from the declared terms — and reconciled againstoffer.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].
feeis the absolute fee in photons; the taker funds it.- Parameters:
- Return type:
- 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.
- pyrxd.bip32_derive_xprv_from_mnemonic(mnemonic, lang='en', passphrase='', prefix='mnemonic', path='m/', network=Network.MAINNET)[source]
Derive the subtree root extended private key from mnemonic and path.
- pyrxd.bip44_derive_xprv_from_mnemonic(mnemonic, lang='en', passphrase='', prefix='mnemonic', path="m/44'/512'/0'", network=Network.MAINNET)[source]
Derives extended private key using BIP44 format- it is a subset of BIP32. Inherits from BIP32, only changing the default path value.
- 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).
- 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).
- 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).
- pyrxd.build_soulbound_nft_covenant(genesis_ref, owner_pkh)[source]
Build a consensus-enforced soulbound NFT covenant SPK.
- Parameters:
- Returns:
With both static guards (exactly-one-ref, no-nonminimal-push) run fail-closed at build time.
- Return type:
SoulboundNftCovenant
- pyrxd.ckd(xkey, path)[source]
ckd = “Child Key Derivation” derive an extended key according to path like “m/44’/0’/1’/0/10” (absolute) or “./0/10” (relative)
- 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 bymaker_key) in exchange forreceivepaid tomaker_receive_pkhin output[0]. The given input is signedSINGLE|ANYONECANPAYso 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.
- pyrxd.generate_secret()[source]
Generate a fresh CSPRNG preimage
pand its hashlockH = SHA256(p).Returns
(p_as_SecretBytes, H_bytes).pis wrapped in the intentionally-unpicklableSecretBytesso it can never be serialised to disk. OnlyHis safe to put inNegotiatedTerms/SwapRecord.- Return type:
- pyrxd.mnemonic_from_entropy(entropy=None, lang='en')[source]
- pyrxd.script_hash_for_address(address)[source]
Return the ElectrumX
script_hashfor 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.
- pyrxd.seed_from_mnemonic(mnemonic, lang='en', passphrase='', prefix='mnemonic')[source]
- 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.
awaitthis 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, absentglymarker, payload mismatch, or a genesis shallower thanmin_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).
Noneskips 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) > 64rejects the 64-byte Merkle forgery.Finding 05-F-9:
pos == 0rejects the coinbase as a payment proof.Finding 05-F-8:
expected_depthmust match branch depth when provided.Finding 02-F-1 / parity:
hash256(raw_tx) == txidbound.
- Raises:
ValidationError – on malformed input (wrong lengths, misaligned branch).
SpvVerificationError – on any defense trigger or root mismatch.
- Parameters:
- Return type:
None