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, a Glyph fungible token, or a Glyph NFT singleton.
amountis 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).refis 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)
- kind: Literal['rxd', 'ft', 'nft']
- amount: int
- exception pyrxd.BroadcastEchoMismatch[source]
Bases:
RxdSdkErrorThe 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 withexcept ValidationError: retrywould re-broadcast a transfer that already moved tokens.Carries
local_txidso the caller can check the chain for what was actually sent.
- 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:
- 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
Truewhen the charge was credited,Falsewhen this input was already credited (idempotent — a second credit would fabricate budget). RaisesValidationErrorfor 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.
- 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.
- exception pyrxd.CekCommitmentMismatch[source]
Bases:
ValidationErrorThe 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:
objectChunked ciphertext + the plaintext SHA-256 used as the per-chunk AAD prefix.
plaintext_hashMUST 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)
- chunks: list[EncryptedChunk]
- plaintext_hash: bytes
- 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, fund_lock=None, accept_estimated_eth_margins=False, min_credential_confirmations=6, role=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:
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, *, 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_deployis an optionalasync (address: str) -> Nonethe 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_fromis 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_noncepin 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.
- 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.CredentialResolver[source]
Bases:
ProtocolIndexer surface to resolve a credential ref to its CURRENT live UTXO.
Mirrors
pyrxd.gravity.ref_authenticity.RefAuthenticityIndexerbut resolves the credential’s current (unspent) locking script — what governs transferability now — rather than the genesis.resolve_credentialis async and MUST raise or returnNone(both fail-closed) when it cannot reach a definitive answer; never return an optimistic stand-in.- __init__(*args, **kwargs)
- class pyrxd.EncryptedChunk[source]
Bases:
objectOne chunk of a chunked-aead-v1 ciphertext.
ciphertextis the bytes returned by the AEAD (includes the 16-byte Poly1305 tag);nonceis the 24-byte XChaCha20 nonce used for this chunk. Photonic emits both fields on the wire — pyrxd preserves them identically for round-trip compatibility.- ciphertext: bytes
- nonce: bytes
- 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, *, 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_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.That stash is MEMORY-ONLY and dies with the process, which is why
on_deploynow 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 andterms.value_amountwas negotiated in the same unit — the unit is carried by the locator TYPE andterms.token_address, not by this method.- 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 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_btcrefuses untilpre_btc_lock_checkstep 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 deploysclaimant=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 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, is_testnet=False)
- 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:
objectSubmit the claim via a Flashbots-style private-tx RPC (
eth_sendPrivateRawTransaction).relay_urlis the private endpoint (e.g.https://rpc.flashbots.net/fast).auth_keyis aPrivateKeyMaterialused ONLY to sign theX-Flashbots-Signaturerequest 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:
relay_url (str)
auth_key (PrivateKeyMaterial)
timeout_s (float)
- Return type:
None
- async submit_raw(raw_tx)[source]
Submit
raw_txprivately; 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_rawguarantee that the node computes the hash from the bytes).
- 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 into a collection
[NFT]+inprepare_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()(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_airdrop_tx(params)[source]
Build one signed transaction paying FT units to many recipients.
Thin delegator to
FtUtxoSet.build_airdrop_tx(), exactly asbuild_ft_transfer_tx()delegates tobuild_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:
- 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:
- 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-recipientFtUtxoSet.build_airdrop_tx(), so the recipient output’s value isparams.amountand 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:
- 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.
- 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
0item_script1authority_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_scriptSTRIPS 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 aHex20here 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_REQUIREINPUTREFin 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, intests/test_authority_regtest_e2e.py— readbuild_authority_gated_nft_script()before treating “gated” as a durable property of the minted item.
- static prepare_burn_proof(token_ref, *, amount=None, burn_reason=None)[source]
The
OP_RETURNoutput 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, andverify_burn()is careful about which parts of it a reader may rely on.
- 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_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
inentry that it can find there (filterRels,packages/app/src/electrum/worker/NFT.ts) — a claimedinref 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
0nft_script1container_script(plus any change).
container_scriptis the container’s OWN current locking script and is re-emitted VERBATIM, so output1is byte-identical to the UTXO being spent by construction rather than by assumption. It previously took aHex20and rebuilt the container withbuild_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_pkhand could be transposed with it.cbor_bytesMUST already declare the membership — encode the child’s metadata withcontainer_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 itsinlist does not containcontainer_ref.- Parameters:
- Return type:
- 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 the7marker in the envelope’spfield, exactly as in Photonic Wallet (packages/lib/src/script.tshas onenftScriptand no container variant). That is what makes a container a first-class token: every NFT classifier, the scanner, andbuild_nft_transfer_tx()handle it unchanged.Membership points child → parent and lives in the child’s envelope, in the
infield (container_refs). Useprepare_container_child_reveal()to mint a member.Protocol field must include
GlyphProtocol.CONTAINER(7).- Parameters:
- Return type:
The
child_refprefix (removed in 0.15.0)¶pyrxd 0.9.0–0.14.0 prefixed the NFT body with
OP_PUSHINPUTREF <child_ref>whenchild_refwas 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_PUSHINPUTREFleaves the ref on the stack and nothing drops it, so the P2PKH tail hashed the ref andOP_EQUALVERIFYfailed for every possible scriptSig. Any photons placed on it were unrecoverable.Creating one destroyed the child NFT.
OP_PUSHINPUTREFSINGLETONalso 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 a0xd0push never re-entersinputSingletonRefSet, so it can never be minted again.
Even with the missing
OP_DROPrepaired, 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 noOP_REFTYPE_OUTPUTobligation, 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 withprepare_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_scriptis returned empty to say so rather than handing back a token script that would be wrong.
- 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/byclaims.This is the alternative to
prepare_container_child_reveal()for a minting service, and the only write path pyrxd has forbyat 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:
Call with authorised_refs only. Spend the container and/or author tokens, with outputs =
base_scriptplus 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_REQUIREINPUTREFis 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_REQUIREINPUTREFrequires 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.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 isOP_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_refasCommitParams.delegate_ref, spends one token in the commit, and emitsRevealScripts.delegate_burn_scriptin 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
RelationshipBasisreports 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_pkhand the outputs were rebuilt withbuild_nft_locking_script(). That is the exact hazardprepare_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)
base_ref (GlyphRef | None)
token_count (int)
- Raises:
ValidationError – authorised_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→ 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. 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_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), carryingref = commit_txid:commit_voutcontract_script: 174-byte mutable contract UTXO (holds state), carryingmutable_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_bytesmust includeGlyphProtocol.MUT(5). UseGlyphMetadata(protocol=[GlyphProtocol.NFT, GlyphProtocol.MUT]).The reveal needs TWO inputs¶
input
outpoint
0commit_txid:commit_vout— the commit (reveal scriptSig)1commit_txid:(commit_vout + 1)— a plain seed outputThe 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_refinto 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
reffor 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 reasonbad-txns-inputs-outputs-invalid-transaction-reference-operations. Two independent chain rules forbid it:OP_PUSHINPUTREFSINGLETONfiles its ref intofoundDisallowedSiblingRefsas well as the push-ref set (CScript::GetPushRefs), andvalidateTransactionReferenceOperationsrejects a transaction where two outputs claim the same one. Both the NFT script and the mutable contract lead with0xd8, 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 + 1is therefore not a convention — the covenant computes it. With equal refs the contract would look forcommit_vout - 1and 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/sloperations ofbuild_mutable_scriptsig()) additionally requires the token output to be re-created in Photonic’snftAuthScriptshape — anOP_REQUIREINPUTREF <mutable_ref> <sha256(contract scriptSig)> OP_2DROPstate prefix ahead of the singleton. pyrxd has no builder for that shape yet; the working transaction is spelled out intests/test_mut_wave_regtest_e2e.py.- 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, allow_confusable=False)[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, at most 255 characters, and must not impersonate Latin text — seepyrxd.glyph.wave.validate_wave_text(), which is the single definition of that rule and is applied here and inbuild_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. Passallow_confusable=Trueto register a look-alike deliberately. 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)].The reveal shape is MUT’s, including its two-input requirement: the commit outpoint plus a seed outpoint at
commit_vout + 1that gives the mutable contract its own singleton ref. Seeprepare_mutable_reveal()— a WAVE registration built without the seed input is rejected by consensus, as every one built through 0.15.0 was.
- class pyrxd.GlyphClient[source]
Bases:
objectMint 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 exposingawait collect_spendable(client),privkey_for_address(address)andaddresses.store – where a
PendingMintlives 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
storeis 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 whenstoreis 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 tobuild_nft_transfertogether, 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]
- async airdrop_ft(ref, recipients, *, allow_overpay=False)[source]
Distribute
refto 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:
ref (GlyphRef)
recipients (Sequence[AirdropRecipient])
allow_overpay (bool)
- Return type:
- 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; callingreveal_timelockafter 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
TimelockRevealBuildthat has not been throughplan_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:
ref (GlyphRef)
recipients (Sequence[AirdropRecipient])
allow_overpay (bool)
- Return type:
- 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:
- 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 throughGlyphBuilder.build_nft_transfer_tx().- Parameters:
- Return type:
- 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.
- 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-rundoes 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.
- 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:
metadata (GlyphMetadata)
supply (int)
- Return type:
- async commit_nft(metadata, *, owner_pkh=None)[source]
Phase 1 of an NFT mint. See
GlyphMinter.commit_nft().- Parameters:
metadata (GlyphMetadata)
- Return type:
- 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 aTypeErrorfrom inside the minter instead of at the call site.- Parameters:
metadata (GlyphMetadata)
supply (int)
- Return type:
- async mint_nft(metadata, *, owner_pkh=None)[source]
Commit and reveal an NFT singleton. See
GlyphMinter.mint_nft().- Parameters:
metadata (GlyphMetadata)
- Return type:
- async mint_timelocked_nft(*, name, content_type, plaintext, params, persist=None, cek=None, recipients=(), locator=None, owner_pkh=None)[source]
Seal
plaintextbehind 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 withchunked-aead-v1, the key’s SHA-256 goes on chain ascrypto.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 tocek.KEY CUSTODY HAS TO PRECEDE THE COMMIT, so this method makes you say how. Supply either
persist— called with theTimelockMintBuildafter the envelope is built and before a single byte is broadcast — orcek, 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, aConfirmationTimeoutError, 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 tosha256(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-mintnever had this problem because it writes its files before broadcasting; the hook is that ordering, for the SDK.persistmay be sync or async, and anything it raises propagates with nothing broadcast. The build it receives carriescek,ciphertext,cek_hash,stubandmetadata— andmetadatais 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 withrecipientscannot reproduce.See
build_timelock_mint()for the rest of the arguments.owner_pkhbehaves as it does onmint_nft(), defaulting to the funding key’s own hash.- Raises:
ValidationError – neither
persistnorcekwas 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 fromget_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_heightchecks only that a non-negative integer came back andget_block_headeronly 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 pastunlock_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 — andpyrxd glyph timelock-revealprints it besideopens atin the pre-broadcast summary, so the operator can disagree with a number that would otherwise never have been on screen. “Summary”, not “prompt”: under--yesthere 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 toplan_timelock_reveal()directly.Everything the plan is checked for happens in the underlying function; see its docstring. This adds only the clock.
- 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_floormust stayNone-defaulted, notFalse— the same trap documented at length onreveal_nft().Nonemeans “inherit the constructor”;Falsemeans “the caller re-asserted the floor for this reveal”. AFalsedefault here would forward a deliberate override on every ordinary call, so a client built withallow_below_relay_floor=Truewould commit and then refuse to reveal, stranding the commit and everything funded into it.- Parameters:
pending (PendingMint)
fee_rate (int | None)
allow_below_relay_floor (bool | None)
allow_overpay (bool)
- Return type:
- 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_floormust stayNone-defaulted, notFalse. The minter readsNoneas “the caller said nothing, inherit the constructor” andFalseas “the caller re-asserted the floor for this reveal”. Defaulting toFalsehere forwarded a deliberate override on every ordinary call, so a client built withallow_below_relay_floor=Truecommitted 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:
pending (PendingMint)
fee_rate (int | None)
allow_below_relay_floor (bool | None)
allow_overpay (bool)
- Return type:
- 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 beforeunlock_atwithoutallow_early. Both areValidationErrorsubclasses, so they land with everything else raised pre-broadcast.- Raises:
CekCommitmentMismatch –
sha256(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_earlywas 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
amountunits ofreftoto_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:
- async transfer_nft(ref, to_pkh, *, allow_overpay=False)[source]
Send the NFT singleton
reftoto_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:
objectParse raw transaction bytes to find Glyph outputs. Pure — no network access.
- classify_glyph_scriptsig(scriptsig)[source]
What kind of
glyenvelope, if any, does this scriptSig carry?THE POINT OF THIS METHOD IS THE THIRD ANSWER.
extract_reveal_metadata()returnsNoneboth 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:
Nonewhen no push equals theglymarker — genuinely not a glyph scriptSig. Otherwise aGlyphEnvelopewhosekindis"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’spayload_hashmust hash the push thatextract_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.
- 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, 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_glyphsbecause a commit has no meaningfulrefuntil 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.CONTAINERinprotocol, surfaced byGlyphMetadata.is_containerandpyrxd.glyph.wave.classify_glyph_metadata()).GlyphScannerdoes this join for you.
- 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='', timelock=None, encrypted_main=None, crypto=None, source_cbor=None, container_refs=(), author_refs=())
- 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)
timelock (TimelockSpec | None)
encrypted_main (EncryptionMetadata | None)
crypto (CryptoMetadata | None)
source_cbor (bytes | None)
- Return type:
None
- commit_outpoint: str = ''
- 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(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 = ''
- 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
- ticker: str = ''
- timelock: TimelockSpec | None = None
- to_cbor_dict()[source]
Build the dict that gets CBOR-encoded (excluding ‘gly’ marker).
- Return type:
- token_type: str = ''
- attrs: dict[str, object]
Glyph
attrscarry non-strings in the wild (Photonic authority tokens use a booleanrevocableand apermissionslist). Values are scalars or lists of scalars;_decode_attr_value()flattens anything deeper. Consumers expecting text shouldstr()what they read, asWaveAttrs.from_dictdoes.- Type:
dict[str, object], notdict[str, str]
- class pyrxd.GlyphMinter[source]
Bases:
objectTwo-phase Glyph minting over an ElectrumX client and an HD wallet.
commit_*broadcasts the commit and returns a persistedPendingMint;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 — andmint_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) -> txidandawait get_transaction_verbose(txid) -> dict. Duck-typed, matchingwait_for_confirmation().wallet – an
HdWallet. Exactly two methods are used —await collect_spendable(client) -> [(utxo, address, privkey)]to fund the commit, andprivkey_for_address(address)to re-derive the reveal’s signing key. Duck-typed likeclient, and deliberately NOT hidden behind a coin-source Protocol:HdWalletis 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 — seeexamples/regtest_quickstart.py, which drives the minter from a single regtest key.store – where the
PendingMintis kept between phases. Required.fee_rate – photons per byte for both transactions.
allow_below_relay_floor – accept a
fee_rateunder 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 raisingConfirmationTimeoutError. ThePendingMintsurvives 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]
- 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 toprepare_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:
ValidationError – on an unsupported protocol mix or a sub-dust supply.
InsufficientFundsError – as
commit_nft().
- Return type:
- async commit_nft(metadata, *, owner_pkh=None)[source]
Broadcast the commit for an NFT singleton mint.
The
PendingMintis persisted and read back before the broadcast.- Parameters:
metadata (GlyphMetadata) – must carry
NFTand 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.CONTAINERis 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:
- async deploy_ft(metadata, *, supply, treasury_pkh=None)[source]
commit_ft()thenreveal_ft(). Seemint_nft().- Parameters:
metadata (GlyphMetadata)
supply (int)
- Return type:
- async mint_nft(metadata, *, owner_pkh=None)[source]
commit_nft()thenreveal_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:
metadata (GlyphMetadata)
- Return type:
- 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 inPendingMint.carrier_value.- Parameters:
pending (PendingMint)
fee_rate (int | None)
allow_below_relay_floor (bool | None)
allow_overpay (bool)
- Return type:
- 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:
pending (PendingMint)
fee_rate (int | None)
allow_below_relay_floor (bool | None)
allow_overpay (bool)
- Return type:
- 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:
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 fetch_metadata(ref)[source]
The mint envelope for
ref, read off the chain.Noneif 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-revealneeds 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.txidalone 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.
metadataisNonewhen 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:
- 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.
- 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.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:
rxd_client (ElectrumXClient)
maker_priv (PrivateKeyMaterial)
btc_source (BtcDataSource | None)
poll_interval_seconds (int)
fee_policy (DeadlineFeePolicy | None)
- 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
ActiveOfferto 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 to1000, ~2,840x under the floor for a 285-byte cancel, which made the documentedcancel_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:
- 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, *, 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
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, as WALL-CLOCK seconds. Returns
Noneon 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.confirmuses, and for the same reason: a fakesleepdoes not advance a real clock.
- 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, # 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:
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, 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_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. 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
1000this 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:
- 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
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. 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:
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:
btc_txid (str) – Bitcoin transaction ID (64 hex chars, big-endian).
min_confirmations (int | None) – Override
config.min_btc_confirmationsfor this call. Bound the same way the config field is (>= 1), which it was not:TradeConfighas refusedmin_btc_confirmations < 1since 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=0returnedconfirmed=True, confirmations=0on the FIRST poll and-5returnedconfirmations=-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-depthN.
- 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, or min_confirmations is given and is below 1.
- 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
- 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
_xprvproperty derives from exactly this path.
- 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_rateis refused below Radiant’s effective relay floor unlessallow_below_relay_flooris set, and above the overpay ceiling unlessallow_overpayis — seebuild_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.
- 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_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).fee_rateis refused below Radiant’s effective relay floor unlessallow_below_relay_flooris set — the deliberate opt-out for regtest and chains you control. UnlikeRxdWallet, the rate arrives per CALL here, so this is where it has to be judged.allow_overpayis 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.
- 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, *, 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=Truerefuses the partial result outright. Usestrictwhen the answer is a claim about all the funds;send_max()does.- Parameters:
client (ElectrumXClient)
strict (bool)
- Return type:
- derive_address(change, index)[source]
Derive the P2PKH address at
change/index(public seam).
- 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:
- 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 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.normalize controls BIP39 NFKD normalization — see
seed_from_mnemonic(). Leave itTrueunless 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.
- async get_balance(client, *, strict=False)[source]
Return total confirmed + unconfirmed satoshis across all known addresses.
Uses
ElectrumXClient.get_balanceper address. Callrefresh()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=Truewhen the number is being shown to somebody or compared against a threshold.- Parameters:
client (ElectrumXClient)
strict (bool)
- Return type:
- 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=Trueto refuse a partial answer instead (see_read_per_address()).- Parameters:
client (ElectrumXClient)
strict (bool)
- 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, *, normalize=True)[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.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 withnormalize=False. Leave the defaultTruefor 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.
- 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).Falseis 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 failureload_or_createwas split out to make visible.- Parameters:
- Return type:
- 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 ofm/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:
- 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).
- privkey_for_address(address)[source]
Derive the signing key for a known address.
The derivation path is looked up in
self.addressesrather than searched for, so this is oneckdchain, 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:
- 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
ValidationErroron bad inputs or insufficient funds,NetworkErroron RPC failure.
- 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. RaisesNetworkErrorif any per-address read failed — nothing is broadcast, and a retry (or a different endpoint) sweeps the whole set. Usesend()for an amount, which does not make that claim.
- 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.JsonFilePendingStore[source]
Bases:
PendingStoreOne 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 byos.open— not chmod’ed afterwards, which would leave a window at the umask’s mercy —fsyncit, thenos.replaceonto 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.- 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.
- load(commit_txid)[source]
Return the stored record, or raise
PendingMintNotFound.- Parameters:
commit_txid (str)
- Return type:
- save(pending)[source]
Persist pending, overwriting any record under the same commit txid.
- Parameters:
pending (PendingMint)
- Return type:
None
- class pyrxd.MarginPolicy[source]
Bases:
objectHow the cross-chain timelock margin is computed and enforced.
- margin
The required minimum
t_rxd - t_btc, 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, 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_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.eth_finalization_window_sis 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 asPAGE_SQUEEZED“verify finality manually”. None keeps the BTC (depth-based) behaviour exactly.
- 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_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_block_interval_fast_sis 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 asrxd_block_interval_sand 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 setaccept_flat_burial=Truefor a dust run — the coordinator refuses a value-bearing swap that leaves them unset.eth_finalization_window_sis REQUIRED (non-None) for a finalized-checkpoint (ETH) counter leg and must stay None for a depth-based (BTC) one; take it frompyrxd.eth_wallet.chains.evm_chain_by_idrather 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_s: float = 300.0
- 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:
objectA signed, un-broadcast mark transaction.
- Parameters:
tx – the signed transaction
fee – photons paid, from the plain-RXD funding input
plan – the checked
MarkPlanthese bytes were built from — carried so a confirmation prompt can show what is about to be published permanently without re-deriving itfrom_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_change –
Falsewhen the whole funding UTXO became the fee
- __init__(tx, fee, plan, from_address, has_change)
- Parameters:
tx (Transaction)
fee (int)
plan (MarkPlan)
from_address (str)
has_change (bool)
- Return type:
None
- serialize()[source]
Raw transaction bytes, ready for
await client.broadcast(...).- Return type:
- tx: Transaction
- fee: int
- plan: MarkPlan
- from_address: str
- has_change: bool
- class pyrxd.MarkPlan[source]
Bases:
objectA 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.recordandattestationare not constructor arguments for the same reason: derived here, they cannot be supplied inconsistently with the bytes.- Parameters:
op_return_script – the
scriptPubKeythat 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)
- 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
Nonewhen 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_hexby construction.
- property size_bytes: int
Size of the record on chain. §3.2 caps it at 223.
- 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:
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_rxd - t_btc >= marginis checked by the coordinator (seeswap_coordinator.assert_timelock_margin), not here — but the raw orderingt_rxd <= t_btcin the same unit is rejected at construction as a cheap fail-closed guard. INVERTED 2026-08-31 (#482): the maker holdspand 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__refusest_rxd <= t_btcand 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 whilepis 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''
- to_dict()[source]
Canonical JSON/hex wire form. NEVER contains the preimage
p.
- 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:
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.PrivateSubmitter[source]
Bases:
ProtocolSubmit a SIGNED raw tx privately and return its tx hash (0x-hex).
The one method
EthHtlcContractLegneeds: it hands over the already-signed raw tx bytes for the claim and gets back the tx hash, exactly likeEthRpc.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)
- class pyrxd.RadiantBroadcaster[source]
Bases:
ProtocolSubmit a raw Radiant tx; idempotent on an already-known tx.
- __init__(*args, **kwargs)
- 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()).fee_policy – The
DeadlineFeePolicythe 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 differenteffective_minrelaytxfee.
- __init__(*, network, taker_pkh, maker_pkh, chain_io, fee_source, min_confirmations=1, audit_cleared=False, fee_policy=None)[source]
- 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_rxdconfirmations deep, sot_rxd - confirmationsis 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.
- 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:
- 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.
- 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_rxddeep (the BIP68 relative-block timelock the covenant was built with:refund_csv=t_rxd.value, mature atconfirmations >= 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 EVERYrefund_assetcaller (mutual_refund,maybe_refund_asset_on_maker_stall) at the leg, complementing the coordinator-side height check inmaybe_refund_asset_on_maker_stall. (The CLAIM branch has no CSV, soclaim_assetis intentionally NOT gated this way.)- Parameters:
record (SwapRecord)
- Return type:
- 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 ofpyrxd.btc_wallet.htlc_leg.BitcoinTaprootLeg.verify_counterparty_funded().WHY:
docs/htlc-handshake-wire-format.mdHZ-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_CHECKSIGwith no precondition that the asset was ever locked, and the maker holds bothpand 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 fullbtc_sats, and the FSM’s nominal “taker locks first” ordering is bookkeeping, not a safety guarantee.What is checked, all fail-closed:
the covenant scriptPubKey is re-derived here from the taker’s own ``terms`` (
_build_covenant()— amount, H,t_rxdCSV, both dest hashes, the asset REF), never taken from anything the maker advertises;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());the funding is buried
min_confirmationsdeep. “Funded” alone is NOT enough: ElectrumXlistunspentincludes 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 withpwhile the vanished covenant leaves the taker nothing to claim.Noneuses this leg’s configuredmin_confirmations; the coordinator passes the policy’s RXD burial depth for a real-value swap.
- 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.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-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, extra_args=())[source]
Start the regtest node, create the dev wallet, and mature a coinbase.
extra_argsare appended verbatim to theradiantdargv — e.g.("-swapindex=1",)to serve the RSWP orderbook RPCs (getopenorders/getopenordersbywant). Ignored when an already running container is reused (start withfresh=Trueto apply).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
- class pyrxd.RevealProof[source]
Bases:
objectParsed reveal proof, mirroring Photonic’s
RevealProoftype.- __init__(v, p, action, token_ref, cek, cek_hash, hint='')
- hint: str = ''
- v: int
- action: str
- token_ref: str
- cek: str
- cek_hash: str
- class pyrxd.RevealValidation[source]
Bases:
objectResult of
validate_reveal_proof().valid: True iff every check passederror: short human-readable failure reason ifvalidis Falseproof: the parsed proof if it was at least well-formed (so the caller can introspect malformed-but-decodable proofs)
- __init__(valid, error='', proof=None)
- error: str = ''
- proof: RevealProof | None = None
- valid: bool
- 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), and is REFUSED below it unless
allow_below_relay_floorsays otherwise.allow_below_relay_floor – Accept a
fee_rateunder 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_rateabove the overpay ceiling (MAX_FEE_OVERPAY_MULTIPLEx 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]
- 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:
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.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:
- 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— 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.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_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_fundedis the only binding, and it previously ran ONLY inside the taker’s ownfund().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_amountand every SPK check still passes. (This method used to REFUSE a BTC counter leg on the grounds that the pre-fundderive==promisedgate 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’staker_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 indocs/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, aBtcHtlcLocatorwhose 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.
- 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; 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.
- 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 WALL-CLOCK margin
t_rxd * i_rxd >= t_btc * i_btc + margin * i_btc(this docstring saidt_btc - t_rxd >= marginuntil 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 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()).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 thepit has held since the envelope). Unfunded / mis-valued / shallow / unreadable => fail-closed.
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 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.
- 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. 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_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_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
pbefore advancing —sha256(p) == Hscraped from the claim AND the per-swap provenance gate (BTC: the claim spends OUR funding outpoint; ETH: it targets OUR HTLC contract and emitsClaimed(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_refis the ETH claim tx HASH (str) or the raw BTC claim tx bytes — exactly whattaker_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.
- 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.
- 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.mdstates this as a normative MUST, and until now no library code enforced it — the check existed only insidescripts/btc_swap_two_host.py, so any caller drivingSwapCoordinatordirectly locked its counter leg against nothing. The maker holds bothpand 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 fullbtc_sats.The Radiant leg re-derives the covenant scriptPubKey from the taker’s OWN
termsand 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 insidetaker_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.
- 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, 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 aBtcHtlcLocator(else None). Lets BTC reader sites keep using.btc_locatoruntil they migrate to the chain-neutralcounterchain_locator.
- counterchain_locator: BtcHtlcLocator | EthHtlcLocator | None = None
- 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.
- 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.
- 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_contractDELIBERATELY: 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]
- 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.TimelockMintBuild[source]
Bases:
objectEverything
build_timelock_mint()produced, and what to do with each part.metadata— hand this toGlyphClient.mint_nft/mint_timelocked_nft. It is theGlyphMetadataview ofstub, 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()andstub.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.
cekisrepr=Falsefor the reasonTimelockMintResultdocuments at length: a default dataclassreprputs the key verbatim into everyprint, 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:
ValidationErrorRefusing 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:
objectParameters for adding a TIMELOCK to a Glyph mint.
Matches Photonic’s
TimelockParamstype.- __init__(mode, unlock_at, hint='')
- hint: str = ''
- mode: Literal['block', 'time']
- unlock_at: int
- class pyrxd.TimelockRecipient[source]
Bases:
objectOne 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 incrypto.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.kidis a free-form label for the wrap (“auctioneer-key-1”). It is operator text, carried verbatim on chain, and authenticates nothing.- kid: str
- public_key: bytes
- class pyrxd.TimelockRevealPlan[source]
Bases:
objectExactly 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 (unlessearly_overrideis set) the timelock has expired.cekis not a field. It is inproof.cekbecause 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)
- early_override: bool = False
Truewhen this plan was built for a still-locked token because the operator passedallow_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, andNonewhen 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_revealtakes 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 inunlockedalone. An operator who can see “tip 812,340” against “opens at 900,000” can notice; one shown only “opens at 900,000” cannot.Noneis 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 caseremainingis 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 whatcekwas checked against.
- mode: str
- unlock_at: int
- unlocked: bool
Truewhen 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:
objectPhotonic-compatible timelock spec embedded in
crypto.timelock.See REP-3009. The on-chain
cek_hashhere is the same value as the parentCryptoMetadata.cek_hash— it’s duplicated inside the timelock object for clear authentication of the reveal transaction.- __init__(mode, unlock_at, cek_hash, hint='')
- hint: str = ''
- mode: Literal['block', 'time']
- unlock_at: int
- cek_hash: str
- 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 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_OUTPUTSsums ref-bearing outputs’ nativenValue(Radiant-Coresrc/script/interpreter.cpp), andFtUtxoREFUSESvalue != ft_amountbecause 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)
- tx_hash: str
- tx_pos: int
- value: PhotonValue
- height: ChainHeight
- exception pyrxd.ValidationError[source]
Bases:
RxdSdkErrorRaised when input fails a trust-boundary validation check.
- class pyrxd.WrappedCEK[source]
Bases:
objectA CEK wrapped to one recipient via X25519 ECDH + HKDF + XChaCha20-Poly1305.
Matches Photonic’s
EncapsulatedSecretshape 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)
- wrapped_cek: bytes
- ephemeral_pubkey: bytes
- 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, 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 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, 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_policyoverrides the rate that floor is derived from, defaulting toDEFAULT_RADIANT_DEADLINE_FEE_POLICY; regtest callers and the CLI’s deliberately sub-floor sizing passes pass their own.- Raises:
InsufficientFundsError – If
feeis below that floor.- 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, *, normalize=True)[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, *, 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.
- 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 reasonpyrxd.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 againsthash256of the signed bytes and RAISES on a mismatch. That helper is imported rather than re-implemented even though it lives underglyph: 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.
- 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 ascript: bytesparameter would have let a caller skip. Theisinstanceguard 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_RETURNat 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:
ValidationError – plan 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:
- 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).
- 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.build_timelock_mint(*, name, content_type, plaintext, params, cek=None, recipients=(), locator=None)[source]
Encrypt
plaintextand 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.hashis the hash of the plaintext,crypto.cek_hashandcrypto.timelock.cek_hashare 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.hashis the AAD prefixdecrypt_chunked()authenticates every chunk against, so a stub whosemain.hashis notsha256(plaintext)yields a token that cannot be decrypted even with the right key.crypto.timelock.cek_hashis 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:
encrypt with
chunked-aead-v1(encrypt_chunked())wrap the CEK to each recipient over X25519, with the CEK-hash commitment as AAD (REP-3006 —
wrap_cek_x25519())assemble the
[NFT, ENCRYPTED]stubadd 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
typeand asmain.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, optionalhint.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:
ValueError –
cekis not 32 bytes, or a recipient key is not a 32-byte X25519 public key.ValidationError –
nameorcontent_typeis 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_PATHuses. 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 — seepyrxd.hd.discovery, which scans all three — but 512 is the one to derive NEW wallets at.
- 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.decrypt_chunked(chunked, key, plaintext_hash)[source]
Decrypt a chunked ciphertext and return the concatenated plaintext.
plaintext_hashMUST 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_hashas a final self-consistency check; mismatch raisesValueError.
- pyrxd.encrypt_chunked(plaintext, key)[source]
Encrypt
plaintextwith the Photonicchunked-aead-v1scheme.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.
- 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.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:
- 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.feedrops 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.
- 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 supplycurrent_block(e.g. from an ElectrumXClient’s tip-height query). Formode="time"the caller suppliescurrent_time(a unix timestamp — typically the latest block’s MTP for strict consensus alignment, buttime.time()is acceptable for UI hints).Accepts either metadata shape — see
_protocols_and_spec().Returns
Trueif the token is not TIMELOCK-marked at all. ReturnsFalseif 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:
- pyrxd.mnemonic_from_entropy(entropy=None, lang='en')[source]
- pyrxd.parse_reveal_proof_script(script)[source]
Parse a reveal-proof OP_RETURN script. Returns
Noneif the script is not a well-formed Glyph TIMELOCK reveal proof.Decodes the bridge fixture’s
op_return_script_hexcorrectly (verified via the testtest_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()andMarkPlan, and the ONE supported way to get bytes intobuild_hashmark_mark(). SeeMarkPlanfor what holding the result means andencode_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.
- 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, withsourceset 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.
- 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_proofhappily emits a self-consistent proof for any 32 bytes —sha256(cek) == proof.cek_hashholds for the wrong key just as well as the right one. Only the mint’scrypto.timelocksays 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_timelockandpyrxd 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 throughvalidate_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_payloadon the mint’s CBOR gives you one;build_timelock_mintgives 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_earlyis False.CekCommitmentMismatch –
sha256(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_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', *, 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. PassFalseonly 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 – seedocs/how-to/recover-funds-across-wallet-paths.md.mnemonic (str)
lang (str)
passphrase (str)
prefix (str)
- Return type:
Note
normalize=Falseis 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’swrapCEKwith the non-PQ X25519 path).Raises
ValueErrorif 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.
- pyrxd.validate_reveal_proof(proof, *, expected_token_ref, expected_cek_hash=None)[source]
Validate a parsed reveal proof’s correctness.
- Checks performed:
action == "reveal"(re-checked even though the parser already did)token_ref == expected_token_refsha256(cek) == cek_hash(self-consistency — proves the CEK the proof publishes actually hashes to the commitment in the proof itself)If
expected_cek_hashis provided,cek_hashmatches it (this is the on-chain commitment from the original mint)
Returns
RevealValidationwithvalid=Trueon success.
- 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.
- 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
- 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.aadis bound to the AEAD wrap — passing differentaadto unwrap fails decryption. Photonic uses the on-chain CEK hash commitment bytes here per REP-3006.