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 broadcast reply named a txid other than the transaction we sent.
Deliberately NOT a
ValidationError, and NOT aNetworkError. Those are what a caller retries on — aValidationErroris raised before anything is sent, aNetworkErrorreads as “the server could not be reached” — whereas this one can only happen after the broadcast, when the transaction may well have relayed. A caller withexcept NetworkError: retrythat rebuilds would send a SECOND transaction, paying twice if the first one went through.Carries
local_txid(hashed from the bytes that were sent) so the caller can check the chain for what was actually sent, andechoed(the server’s claim, unverified).Raised by
pyrxd.network.electrumx.verified_broadcast_txid(), the one echo check; re-exported aspyrxd.glyph.client.BroadcastEchoMismatchandpyrxd.BroadcastEchoMismatch.
- 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, funding_bound=ElapsedBoundPolicy(surge_factor=3.0, loss_budget_photons=100000000, epsilon_min=1e-12, epsilon_max=0.001, early_slack_s=3600, early_work_margin=2.0, dust_threshold_photons=100000000000, accept_single_operator_up_to_photons=None))
- Parameters:
- Return type:
None
- accept_estimated_eth_margins: bool = False
- accept_nondurable_seen: bool = False
- fund_lock: Any = None
- funding_bound: ElapsedBoundPolicy = ElapsedBoundPolicy(surge_factor=3.0, loss_budget_photons=100000000, epsilon_min=1e-12, epsilon_max=0.001, early_slack_s=3600, early_work_margin=2.0, dust_threshold_photons=100000000000, accept_single_operator_up_to_photons=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, push_tx_hash=None, on_push_hash=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.push_tx_hash/on_push_hashare the same for the pushed transaction’s HASH:on_push_hash(nonce, tx_hash)is awaited between signing and sending, so the caller can persist the hash before the broadcast, andpush_tx_hashhands it back on a resume so the leg can read the pending push and price a replacement. Legs without a push accept and 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. Passed torequire_audit_cleared, which has been a no-op since 0.9.0, so no tag stops this leg constructing.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 – Accepted for backward compatibility and has no effect, exactly as on the BTC leg: it feeds
require_audit_cleared, a no-op since 0.9.0. The ETH bridge is unaudited and this flag does not stop it running on any network.
- __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().
- property chain_id: int
The EIP-155 chain id this leg signs for — what decides whether it moves real value.
pyrxd.gravity.funding_spv.radiant_chain_for_leg()reads it:networkcannot say whether an EVM chain carries value (seepyrxd.eth_wallet.chains), the chain id can.
- 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
- async close_progress(tx_hash, nonce)[source]
What happened to our push-closing transaction (
RefundProgress: the same reads as for a refund, by hash and nonce). Read-only.
- async close_push_nonce(nonce, *, on_signed=None)[source]
Consume
nonceof this leg’s key with a 0-value self-transfer (#850 PR 7a, design §2.7;EthHtlcContractLeg.close_nonce()).on_signedgets(tx_hash, nonce)before the broadcast.
- async counter_disposition(locator, *, recorded_refund_hashes=(), expiry_anchor=None, recorded_claim_hashes=(), push_nonce_bound=None, push_signer=None, nonce_less=False)[source]
What the chain says about this swap’s HTLC, read and verified at the finalized block (
pyrxd.eth_wallet.disposition.read_counter_disposition(), #850 PR 5a). Read-only; never raises for a chain read (an unreadable chain isUNKNOWN).A token HTLC (
Erc20HtlcLocator) is also read forEXPIRED_EMPTY_*(#850 PR 7a): its token balance, and the push key’s nonce at the finalized block againstpush_nonce_bound. The push key ispush_signerwhen the record names it. A record that does not (one written before it was kept) is read with this leg’s key only when this key deployed the contract (deployed_by_this_key()); otherwise no nonce is read and the contract can only readEXPIRED_EMPTY_OPEN.nonce_lessis passed through.
- async created_by_this_key(contract_address, deploy_tx_hash)[source]
deployed_by_this_key()by address and deploy hash, for a contract with no locator yet (an interrupted fund’s pending deploy). A read that fails isFalse.
- async deployed_by_this_key(locator)[source]
Whether this leg’s key created
locator’s contract: the CREATE address of this key at the deploy transaction’s nonce is the contract address. Only the nonce comes from the endpoint, and the derivation checks it, so a lying endpoint cannot make this true for another key’s contract. A read that fails isFalse.- Parameters:
locator (EthHtlcLocator)
- Return type:
- expected_locator(terms, *, contract_address, deploy_tx_hash=None, deployer=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); without one the locator carriesUNKNOWN_DEPLOY_TX_HASH. The runners’ record merge does not compare the field, and a log scan from it fails closed (no such transaction).deployer(token leg only, #850 PR C) is the contract’s creator asErc20HtlcLeg.derive_deployer()derived it from the chain; without one a token locator’sdeployerimmutable has no value and verifying it fails closed. A native leg refuses one.
- 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, push_tx_hash=None, on_push_hash=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
- async is_settled(locator)[source]
Whether the contract’s
settledstorage flag is set at the tip (claimed or refunded).The same storage read the leg’s own claim and verify paths make (
EthHtlcContractLeg._settled_word()).- Parameters:
locator (EthHtlcLocator)
- Return type:
- 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:
- async observed_claim_tx(locator)[source]
The tx hash of a claim on this swap’s HTLC, found in the contract’s logs, or
None.The watchtower’s scan (
RpcEthChainSource.claim_status()), reused rather than re-implemented: every log from the per-swap contract between its deploy block and the head, aClaimedlog preferred,Refunded()-only read as unclaimed. A HINT, not a verdict — the caller verifies it like any reveal (scrape_secretwithsha256 == H, thenassert_claim_provenance()) before acting on it. Read-only; holds no key.- Parameters:
locator (EthHtlcLocator)
- Return type:
str | None
- async refund(locator, timeout=None, *, on_signed=None, replace=None)[source]
- Parameters:
locator (EthHtlcLocator)
- Return type:
- async refund_estimate(locator)[source]
eth_estimateGasfor a refund of locator now. Read-only.- Parameters:
locator (EthHtlcLocator)
- Return type:
- async refund_progress(tx_hash, nonce, *, earlier=())[source]
What happened to our refund
tx_hashatnonce(RefundProgress). Read-only.
- async refund_revert_reason(locator)[source]
The contract error a refund of locator would revert with now, or
None. Read-only.- Parameters:
locator (EthHtlcLocator)
- Return type:
str | None
- 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.
- signer_address()[source]
This leg’s key’s address, lower-case: the key that deploys, pushes and closes.
- Return type:
- async unfunded_token_balance(*, token, contract, deploy_tx_hash=None)[source]
Whether a token contract this swap deployed and never pushed into holds no tokens, at the finalized block and at the tip at every endpoint (#850 PR 8,
pyrxd.eth_wallet.disposition.read_unfunded_token_balance()), and whether the key that would push into it is idle. That key is this leg’s key only when it created the contract (deploy_tx_hash); otherwise it is not known andfunding_key_idleisNone, as it is when the deploy cannot be read (created_by_this_key()reads it from one endpoint). Read-only; raisesNetworkErrorwhen the balance or the key’s nonces are unknown.
- 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, *, pay_registration_fee=True, registration_treasury=None)[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.If
cbor_bytesis marked WAVE and registers a name, the result carries its registration fee (pay_registration_fee,registration_treasury: asprepare_mutable_reveal()), paid after both token outputs.- Raises:
ValidationError – the envelope is unparseable or does not include
GlyphProtocol.NFT.- Parameters:
- Return type:
AuthorityGatedRevealScripts
- static prepare_burn_proof(token_ref, *, amount=None, burn_reason=None)[source]
The
OP_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.A WAVE claim outside the rule pyrxd writes claims by is refused HERE, before the commit exists: once a commit is broadcast it can only be spent by revealing exactly the CBOR it commits to, so a refusal at reveal time would strand its value. There is no override on this path (see
refuse_unregistrable_wave_claim()).Nothing for a WAVE claim’s registration fee goes in the commit. The reveal pays it, from a second, plain wallet input (Photonic’s shape; see
prepare_wave_reveal()), so the fee stays in the wallet until the reveal that registers the name spends it.wave_registration_fee_for(result.cbor_bytes)gives the amount before anything is broadcast.- Parameters:
params (CommitParams)
- Return type:
CommitResult
- prepare_container_child_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, container_ref, *, container_script, pay_registration_fee=True, registration_treasury=None)[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.If
cbor_bytesis marked WAVE and registers a name, the result carries its registration fee (pay_registration_fee,registration_treasury: asprepare_mutable_reveal()), paid after both token outputs.- 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, *, pay_registration_fee=True, registration_treasury=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.If
cbor_bytesis also marked WAVE and registers a name, the result carries its registration fee (pay_registration_fee,registration_treasury: asprepare_mutable_reveal()).
- prepare_dat_commit(params)[source]
Prepare a DAT (data-storage) commit — a glyph whose commit demands no token.
Same two-transaction shape as
prepare_commit(), but the commit carries noOP_REFTYPE_OUTPUTobligation: the reveal need create no NFT or FT (nor is it prevented). What survives is the reveal’s payload.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 commit demands no token, 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, *, allow_unregistrable_wave=False, pay_registration_fee=True, registration_treasury=None)[source]
Prepare scripts for a MUT (mutable NFT) reveal.
A payload marked WAVE is held to the WAVE claim rule (
refuse_unregistrable_wave_claim()) unlessallow_unregistrable_wave=True, which exists only to reveal a commit pyrxd ≤0.24.0 already broadcast; whether the claim so revealed registers is the indexer’s rule (seeprepare_wave_reveal()), and when it does it owes the fee. How to rebuild that commit’s exact bytes is inprepare_wave_reveal().A payload that registers a WAVE name owes the registration fee:
registration_fee_outputon the result, which the caller puts at vout 2 and funds from a plain wallet input (seeprepare_wave_reveal()).pay_registration_fee=Falsedeclines it andregistration_treasuryredirects it; seewave_registration_fee_for().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.
- prepare_reveal(params)[source]
Prepare the reveal transaction scripts.
Returns locking script + scriptSig suffix. Caller must build, sign, and broadcast the actual transaction.
A payload that registers a WAVE name also gets
RevealScripts.registration_fee_output, which the reveal must pay unlessparams.pay_registration_feeisFalse.pyrxd glyph mint-nftreaches here with a WAVE metadata file, and puts that output at vout 1, after the NFT.- Parameters:
params (RevealParams)
- Return type:
RevealScripts
- prepare_wave_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, name, *, allow_unregistrable_wave=False, pay_registration_fee=True, registration_treasury=None)[source]
Prepare scripts for a WAVE (on-chain naming) reveal.
THE RESULT CARRIES THE REGISTRATION FEE, AND THE CALLER PAYS IT.
registration_fee_outputis aWaveRegistrationFee: an output ofwave_registration_price(name)photons — 100 RXD for a label of 3 characters or fewer, 50 for 4, 10 for 5, 5 for 6 to 63 — to the P2PKH of the WAVE treasury (WAVE_TREASURY_ADDRESS). This method returns scripts; the caller builds the transaction, in Photonic’s shape (mintToken) and mainnet claimf644794b…’s:input 0 commit_txid:commit_vout the commit (scriptSig: sig, pubkey, envelope) input 1 commit_txid:commit_vout + 1 the seed for the contract's ref input 2 a plain P2PKH wallet UTXO funds the fee (f644794b: its commit tx's vout 2) vout 0 nft_script the name's claim token vout 1 contract_script the mutable contract vout 2 registration_fee_output (value, locking_script) vout 3+ change back to the wallet
FUND THE FEE FROM THE WALLET INPUT, NOT FROM THE COMMIT: then the fee is spent only by the reveal that registers the name, and a reveal that does not pay it (say, because the name was taken after the commit) leaves it in the wallet. Check that
pyrxd.glyph.wave.WaveResolver.check_available()still reports no confirmed registration just before broadcasting (it does not see a rival claim that is not yet mined);pyrxd glyph mint-nftdoes all of this.pyrxd.glyph.wave_rulescites the sources.pay_registration_fee=Falseregisters the name without paying. The published protocol and Photonic expect the fee; RXinDexer does not check it at registration, so the name still resolves; renewing it later takes a transaction that spends the claim token and pays the price to the treasury. It is also how a commit whose name was taken in the meantime is recovered: the reveal is then a duplicate the indexer does not register.registration_treasurypays another P2PKH address instead — the published treasury is a MAINNET address, and no testnet or regtest one exists. Seewave_registration_fee_for().nameis the name being claimed:"alice.rxd", or a bare"alice", which means the same. It must already be embedded incbor_bytes, with the fields Photonic Wallet’screateWaveNameMetadatawrites (packages/lib/src/wave.ts):{ "v": 2, "p": [2, 5, 11], "name": "alice.rxd", "type": "wave_name", "attrs": { "name": "alice", "domain": "rxd", "target": "<radiant_address>", "target_type": "address" } }
Use
pyrxd.glyph.wave.build_wave_metadata()to construct it, and pass the same payload toprepare_commit()first — which applies the same rule, so a claim that would be refused here is refused before its commit can be broadcast.REFUSED, by
refuse_unregistrable_wave_claim()— the one rule every WAVE write door calls — any payload outside the rule pyrxd writes claims by, which is stricter than the indexer’s: a label outside 3-63 characters of lowercasea-z,0-9and-(so pyrxd’s own ≤0.24.0 shape,attrs.name = "alice.rxd", which the indexer does not register, andaborAlice, which it does), a domain other thanrxd, a claim with noattrs.name, and a top-level orapp.dataname naming a different claim. Thennamemust be the name the indexer will read from the payload.allow_unregistrable_wave=Trueskips both checks and only confirmsnameis a name the payload carries. It is for ONE job: revealing a commit pyrxd ≤0.24.0 already broadcast, whose CBOR has the old shape. That commit can only be spent by revealing exactly those bytes, so refusing them would strand its value. WHETHER THE CLAIM SO REVEALED REGISTERS is the indexer’s rule, not pyrxd’s: a dottedattrs.name(the qualified name pyrxd ≤0.24.0 wrote when given one) does not, and the reveal only recovers the commit; a bare label the indexer accepts (≤0.24.0 wrote one when given a bare name such as"ab"or"Alice", which pyrxd now refuses) does, and owes the registration fee, whichregistration_fee_outputcarries — the fee followswave_registered_label(), the indexer’s rule.REBUILDING THOSE BYTES. Use the
cbor_bytesstored when the commit was made (CommitResult.cbor_bytes) if you have them.build_wave_metadata()no longer produces them, but every release that shipped it (v0.6.0 to v0.24.0) wrote the same bytes, and this reproduces them:from pyrxd.glyph.payload import encode_payload from pyrxd.glyph.types import GlyphMetadata, GlyphProtocol old_cbor, _ = encode_payload( GlyphMetadata( protocol=[GlyphProtocol.NFT, GlyphProtocol.MUT, GlyphProtocol.WAVE], attrs={ "name": qualified_name, # EXACTLY the string passed then: "alice.rxd" "domain": domain, # the text after its LAST ".", or "rxd" if none "target": target, "target_type": target_type, # "address" unless another was passed }, description=description, # the same description, "" if none was passed ) )
Checked byte for byte against the output of every tagged release from v0.6.0 to v0.24.0, recorded in
tests/fixtures/wave_build_metadata_v0_6_to_v0_24.json(tests/test_wave_claim_registers_with_the_indexer.py,TestTheRecoveryRecipe). Before broadcasting, confirmhash_payload(old_cbor) == extract_payload_hash_from_commit_script(commit_script). If the bytes differ in any way, the commit script’sOP_HASH256 <hash> OP_EQUALVERIFYfails and consensus rejects the reveal. Nothing is lost: the commit stays unspent, and the reveal can be rebuilt with the right bytes and tried again.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:
store = JsonFilePendingStore(Path("~/.pyrxd/pending").expanduser()) client = GlyphClient(electrumx, wallet, store=store) 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), andawait get_history(script_hash), which anHdWalletcalls for the gap-limit scancollect_spendableruns before every spend.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:
<0x04 nonce(4)> <0x20 inputHash(32)> <0x20 outputHash(32)> <OP_0> → 72 bytes <0x08 nonce(8)> <0x20 inputHash(32)> <0x20 outputHash(32)> <OP_0> → 76 bytes
Where:
nonce— little-endian PoW nonce found by the miner. Its width does NOT say whether the contract is V1 or V2. Neither covenant checks it — each concatenates the nonce into the proof-of-work preimage (V1 epilogue5a 7a 7e, V2 Part A5e 7a 7e) — and V1 mints on mainnet use both widths: 465 of 473 mints of V1 contracts in a sample collected on 2026-09-23 pushed 8 bytes. The contract script the input spends is what tells V1 from V2 (DmintState.from_script). This payload used to carry aversion_hintread off the width, which called those V1 mints “v2”; it carriesnonce_widthinstead.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,nonce_width(4 or 8) 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_vout=None, 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_vout (int | None)
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 = ''
- loc_vout: int | None = None
An INTEGER
loc, kept rather than discarded. READ PATH ONLY — never encoded.pyrxd reads
locas a text URI. Photonic reads it as a REF VOUT: an integerlocpoints at one of the token’s own refs, whose payload Photonic fetches and MERGES into this one (packages/app/src/electrum/worker/NFT.ts:988-1010). The two implementations do not mean the same thing by the field, and pyrxd was dropping the integer form with a warning — so a token whose real metadata lives in a second payload decoded as though it simply had noloc, indistinguishable from one that never had any. That is a silent partial read of third-party data.THE MERGE IS NOT IMPLEMENTED. Following the pointer means fetching another output mid-decode, which this decode path deliberately does not do. Surfacing the vout is the honest half: a caller can see the payload is incomplete and go and get the rest.
Nonemeans no integerlocwas present, not that a merge was performed.
- 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(Path("~/.pyrxd/pending-mints").expanduser()) 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(). Once the reveal is reported confirmed the record is archived (PendingStore.archive()), never deleted; on failure it is kept where it is so the reveal can be retried. An archived record (JsonFilePendingStore.load_archived) can be passed here again — through another server, if the one that reported the reveal confirmed was wrong.- 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, *, strict=False)[source]
Return all Glyph outputs currently owned at address.
Reads the script hashes the address’s token outputs are listed under (
owned_token_script_hashes()), not its P2PKH hash: a Radiant ElectrumX lists a token output under its script with the refs zeroed, so the P2PKH hash lists the address’s plain outputs only. A token the server lists here whose owner is not address’s key is refused as a server inconsistency, not returned.- Parameters:
- 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, *, strict=False)[source]
Return all Glyph outputs for script_hash.
Fetches UTXOs, raw transactions, and (where available) reveal transaction metadata, then constructs typed GlyphNft / GlyphFt objects.
What the server says is checked against the transaction it serves, which
get_transactionbinds to the txid. A UTXO whose output is not at script_hash (or does not exist), or is listed twice, is a server inconsistency, and so is a token whose owner is not the addressscan_address()was asked about. An FT’s amount is the output’s value in that transaction, never the server’svalue. That a listed output is still unspent is the server’s word: nothing here checks it.A UTXO whose raw transaction cannot be fetched or parsed, or that is a server inconsistency, is logged and left out by default. For a failed read the result is then a lower bound on what the script hash holds; an inconsistent item was never shown to be held here.
strict=TrueraisesNetworkErrorinstead; use it when the result is shown as everything held (pyrxd glyph listdoes). A metadata lookup that fails does not count: it leaves the token in the result withmetadata=None.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.
Deprecated since version Deprecated: and UNGATED: nothing verifies that the maker’s covenant exists on chain before you pay. Between
claim()and the taker’s BTC payment, the only Radiant-side evidence read is the claim tx’s broadcast acknowledgement from a single server — one lying server can get you to pay the full BTC amount for a covenant that does not exist. Use the HTLC swap (SwapCoordinator) instead. Constructing this class emits aDeprecationWarning.- 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=True)[source]
Return
(utxo, address, privkey)triples for every UTXO across known addresses.Address→key mapping is preserved so signing works correctly per UTXO.
Runs the gap-limit scan (
refresh()) first, every call. An address is read here only once it is markedused, only the scan marks it, and the mark is not something a caller can be trusted to have made: before this, every CLI spend command butwallet send/wallet sweepcollected from a wallet nobody had scanned, so a freshly created, funded wallet reported nothing to spend (#759). Scanning on every call, not once per wallet, is deliberate too: a command that spends twice (a mint’s commit, then its reveal) needs the change address the first spend just used. A scan that cannot read an address raisesNetworkErrorrather than reading it as unused (seerefresh()).strict(the default) raisesNetworkErrorwhen any address’s UTXO read fails, instead of returning what the other addresses answered. Almost every caller turns a short result into a claim about the whole wallet — “fund this wallet”, “no spendable UTXOs”, “not held by this wallet” — and after a failed read that claim is false:pyrxd markprinted “fund this wallet” whenget_utxosfailed for the one funded address.strict=Falsereturns the partial view, LOGS the failed reads and names them inSpendable.unread; it is for a caller that only needs enough (send(),pyrxd wallet send,pyrxd swap cancel), which must go throughraise_if_reads_failed()before it reports that what it read is not enough. The scan above fails closed either way.- Parameters:
client (ElectrumXClient)
strict (bool)
- Return type:
Spendable
- 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 an address this wallet derives.
The path is looked up in
self.addressesfirst. An address not recorded there is searched for locally, with no network: both chains, from index 0 to_GAP_LIMITpast the highest index the wallet knows on that chain. A wallet file records only what something saved, andpyrxd wallet newsaves before it derives a single address, so without the search a new wallet could not find the key for its own first receive address. On regtest that stranded a new wallet’s Glyph commit:glyph resume-mintrefused to reveal it.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 derives within that window — the caller has the wrong wallet (or an address past the gap limit), 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.An address already recorded as used is never marked unused by a scan, even when this server reports no history for it: a server that lags or indexes partially does not get to hide funds the wallet already knew about.
An address counts as used when its P2PKH script hash has history, or, when that is empty, when any of its token script hashes does (
_has_token_history()). A Radiant ElectrumX lists a token output under the token script’s hash with its refs zeroed, not under the owner’s P2PKH hash, so an address that has only received a Glyph token has no P2PKH history at all (#787).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.Collects with
strict=False: an amount send needs enough, not all, so an address whose UTXO read failed is logged and skipped rather than refusing a send the other addresses can fund. The gap-limit scan before it still fails closed. When what was read is short AND a read failed, it raisesNetworkErrorrather than calling that insufficient funds (raise_if_reads_failed(), insidebuild_send_tx()).
- 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.directory is used as given:
~is NOT expanded, so passPath("~/.pyrxd/pending-mints").expanduser(), not the bare string, or the records land in a directory literally named~under the current one (#755).- ARCHIVE_DIRNAME: ClassVar[str] = 'done'
a subdirectory
list_pending()does not read.- Type:
Where
archive()moves a record
- archive(commit_txid)[source]
Move the live record to
done/: a rename, so it keeps its 0600 mode, into a 0700 directory. Does nothing if there is no live record. SeePendingStore.archive().Raises
ValidationError, leaving the record where it is, whendone/is not a real directory (archive_directory_problem(), which reads it withlstat). On POSIX thechmodand the rename act on a descriptor ofdone/opened withO_NOFOLLOW(_open_archive()), never on its path, so neither can follow a link — not even one swapped in after thelstat(lane D F2: achmodof the path followed a link and made a foreign directory 0700).- Parameters:
commit_txid (str)
- Return type:
None
- property archive_directory: Path
Where archived records are kept (
<directory>/done).
- classmethod archive_directory_problem(directory)[source]
Why the archive of a store at
directoryis unusable, orNone. Creates nothing.Read with
lstat, so a symbolic link is seen as a link:done/must be absent or a real directory. A regular file there makes themkdirfail; a link can lead to another filesystem (a rename then fails withEXDEV) or to a directory that is not the store’s (on3cb57e27achmodof the path followed one and made it 0700; lane D F2).
- archive_problem()[source]
Why
archive()could not work in this store, orNone.GlyphMinterasks before it broadcasts the commit and again before the reveal, and refuses on an answer: an archive that fails after a reveal is reported confirmed would otherwise be found only when the mint is finished. The default has nothing that can fail.- Return type:
str | None
- delete(commit_txid)[source]
Drop the record. Must not raise if it is already gone.
GlyphMinternever calls this: seearchive(). It is here for a caller who has seen the reveal on chain for themselves and wants the record gone.- Parameters:
commit_txid (str)
- Return type:
None
- property directory: Path
Directory holding the records.
- list_archived()[source]
Commit txids with an archived record. Refuses a
done/that is not a real directory.
- 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:
- load_archived(commit_txid)[source]
The archived record for
commit_txid;PendingMintNotFoundif there is none.A read: it changes no mode and builds no second store (lane D N1 — a store built on
done/chmodded it by path, through a link). Refuses adone/that is not a real directory, asarchive()does.- Parameters:
commit_txid (str)
- Return type:
- restore(commit_txid)[source]
Move an archived record back to the live directory, to reveal it again — renamed out of a
done/descriptor opened withO_NOFOLLOW, asarchive()renames into one.- Parameters:
commit_txid (str)
- Return type:
None
- 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. So it must be 64 lowercase hex and the genesis of a chain pyrxd knows: the attestation check below verifies against THIS string, so on its own it is circular for exactly this field — bytes signed for
"mainnet"verify against"mainnet".source – what was digested, for a confirmation prompt to show. Local only; no part of it reaches the chain.
allow_unknown_genesis – accept a well-formed genesis pyrxd has no constant for. See
encode_hashmark().
- __init__(op_return_script, network_genesis='0000000065d8ed5d8be28d6876b3ffb660ac2a6c0ca59e437e1f7a6f4e003fb4', source=None, allow_unknown_genesis=False)
- property algorithm: str
- allow_unknown_genesis: bool = False
- 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. The coordinator reads it to decide whether the swap is value-bearing; the leg itself constructs on any tag.
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 – Accepted for backward compatibility and has no effect: it feeds
pyrxd.btc_wallet.htlc_leg.require_audit_cleared(), a no-op since 0.9.0.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.
- configured_depth_operators()[source]
The operator groups this leg asks for the maker’s funding depth (
RadiantChainIO.configured_depth_operators()): what the coordinator checks, before anyone locks, against the taker gate’s two-operator rule above dust.
- 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 covenant_spend(record, *, burial_blocks)[source]
What spent this swap’s covenant, read from every configured source (#850 PR 11).
The covenant is the one the terms build, at the outpoint the record pins (the outpoint
refund_asset()spends). Each source names the spender from its own history (RadiantChainIO.covenant_spend_answers()); they must all name the same one, or all hold the outpoint unspent, or the answer isUNKNOWN. The spender is classified from its own bytes by the leaf it took, never by its txid: the refund leaf’s scriptSig is the selectorOP_1alone and its single output pays the maker’s holder script; the claim leaf’s opensHand pays the taker’s. Consensus binds each leaf to its output, so a spend that matches neither isUNKNOWN.buriedis the depth a quorum vouches for at or above burial_blocks (CovenantSpend). A source whose history of the covenant script is too long to read (MAX_COVENANT_HISTORY) makes the answerUNKNOWNtoo; the record’s own refund (asset_refund_txid) is still checked at such a source. Never raises for a chain that cannot be read: that isUNKNOWNwith the reason.- Parameters:
record (SwapRecord)
burial_blocks (int)
- Return type:
CovenantSpend
- 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 maker_funding_evidence(terms, *, header_ranges, min_confirmations=None)[source]
TAKER-side: fetch the evidence the coordinator PROVES the maker’s covenant funding from.
The swap taker gate (
SwapCoordinator.taker_verify_asset_funding) calls this and runspyrxd.gravity.funding_spv.verify_maker_funding()on what it returns; nothing is judged here. The covenant scriptPubKey is re-derived from the taker’s ownterms, and itslistunspententry is used only to LOCATE the outpoint and the height the server names for it — the script and value the coordinator accepts are read from the funding transaction’s own raw bytes, and the height is proved or refused. The same read is what still stands behind “the output is unspent”, which SPV cannot show.header_ranges is a callable
height -> ((start, count), ...): the coordinator decides what to fetch once the height is known. min_confirmations, when given, is the depth the coordinator already knows it will require; a server that itself reports less is refused here before thousands of headers are fetched. That refusal is the only use of the reported depth on this path: a server over-reporting it gains nothing, because the proof decides.
- 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]
A SERVER-REPORTED pre-check and locator of the maker’s covenant funding — NOT the taker gate.
Returns
(outpoint, value_photons, confirmations)as ONE server reports them (listunspentand verboseconfirmations: no merkle proof, no header), and RAISES on anything short of the checks below. A server that invents the covenant satisfies it, so passing it proves nothing a lying server cannot fake. The taker gate ispyrxd.gravity.swap_coordinator.SwapCoordinator.taker_verify_asset_funding(), which PROVES the funding frommaker_funding_evidence()withpyrxd.gravity.funding_spv.verify_maker_funding()and no longer calls this. Its production caller is the coordinator’s_covenant_elapsed_blocks, the post-confirm ordering recheck’s read of the covenant’s depth — a measurement there, not a gate.The rule it was written for:
docs/htlc-handshake-wire-format.mdHZ-1 — “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 and a mis-valued one raise; SEVERAL matches do not (the earliest-confirmed is selected: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'
- __init__(*, container=None)[source]
containernames a node other than the CLI’spyrxd-devnet.pyrxd.testinguses it so a test session gets its own throwaway chain and never touches (or force-removes) the node a developer started withpyrxd regtest up. Omitted, every instance sharesCONTAINER, which is what lets separate CLI invocations reach the same node.- Parameters:
container (str | None)
- Return type:
None
- 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 (the gate is advisory).
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.now_unix_s – The caller’s wall clock at construction (the coordinator never reads it itself). Used only by the negotiation-time check on a NEGOTIATED record on a value-bearing Radiant network, where an ETH or ERC-20 counter leg’s timelock ordering is judged against the absolute
eth_timeout_unix_sfromnow— so such a coordinator is refused without it.maker_funding_confirmations – The confirmations of the maker’s covenant funding the CALLER has already observed at
now_unix_s, orNone(the default: not observed, e.g. a coordinator built before the maker funds). Read only by the construction-time projection of when the taker’s gate can first accept the funding (_taker_gate_first_acceptance_wait()):kminus this many blocks remain to wait for. A runner resuming a swap whose funding is already on chain passes the depth it read. It can only shorten that projection; steps 3, 5, 6 and 7 read the chain themselves. Passing a depth for a funding that does not exist removes the one check that keeps the maker from locking into terms whose deadline the taker’s gate cannot meet.
- ABANDON_NOTE_MAX_CHARS = 2000
The longest operator note
abandon_counter_legkeeps, in characters. CHOSEN.
- __init__(*, record, counter_leg=None, btc_leg=None, radiant_leg, indexer, seen_store, config, persist=None, credential_resolver=None, now_unix_s=None, maker_funding_confirmations=None)[source]
- async abandon_counter_leg(*, confirm_contract, reason_code, note)[source]
End the record ABORTED on a counter-leg case that has no automatic end (#850 PR 7b, design §3.6). Valid from BTC_LOCKED and BOTH_LOCKED (not for a maker’s record) and from PARAMS_MISMATCH (any role). ETH counter leg only. Nothing is broadcast; the counter-leg locator stays on the record, so a refund by address remains possible.
The chain is read first (every gate read must succeed; a failed, below-quorum or contradictory read refuses and records nothing), and the case it shows must be past the timeout at the finalized block or already settled there, so no claim can succeed again:
case (
reason_code)BTC_LOCKED
PARAMS_MISMATCH
BOTH_LOCKED
EXPIRED_EMPTY: OPENyes
yes
yes
EXPIRED_EMPTY: CLOSEDno (edge)
yes
yes
PAYOUT_REVERTSyes
yes
yes
CLAIMED_AFTER_MISMATCHno (edge)
yes
no (edge)
SETTLED_UNVERIFIABLEyes
yes
yes
SINGLE_SOURCE_REFUNDyes
yes
yes
confirm_contractmust be the locator’s contract address, andreason_codethe case the read shows; either mismatch refuses (AbandonNotAccepted), as does a case with an edge of its own (the error names that step).note(the operator’s reason, at mostABANDON_NOTE_MAX_CHARSprintable characters) is written to a sidecar file through the persist hook’swrite_abort_note(JsonFileRecordSink, design D18); the record carries only its SHA-256,abandon_caseandabort_reasonOPERATOR_ABANDONED, with the read ascounter_disposition. A recordedClaimedlog does not hold this step (D14): it is the operator’s choice, made with that page in front of them, and everyClaimedlog the read saw goes into the sidecar, and the first onto the record ascounter_claim_tx. The exception isSETTLED_UNVERIFIABLE: it is accepted only on the read that checked every gate without a contradiction (settled at the finalized block, that block at or past the timeout, every nominated transaction checked), and refused while aClaimedlog that opens H is seen or on the record, since that log carriesp. A claim on the record is nominated to the reader by its hash. Every case butSINGLE_SOURCE_REFUNDread from one endpoint on a chain that carries value raisesNetworkError(D4), and a record on disk at another state, or holding a claim this record lacks, is refused.What it gives up: on
SETTLED_UNVERIFIABLEthe contract may have been CLAIMED, sopmay be public and the covenant claimable until t_rxd; onEXPIRED_EMPTYa token push may still land; onPAYOUT_REVERTSthe tokens stay in the contract. The record stops being followed. A single operator’s BOTH_LOCKED covenant is not touched: refund it by its CSV leg at t_rxd.
- async abandon_unfunded(*, confirm_btc_htlc_empty=False)[source]
End a NEGOTIATED record that holds nothing that may hold value (
TAKER_NEVER_FUNDS; #850 PR 8, design §3.5). The taker’s step; a maker’s NEGOTIATED exit ismaker_refund_covenant()(#850 PR 11).Refused, with nothing recorded, when the record holds:
a BTC funding transaction (
pending_btc_funding_tx): it is written before the broadcast, so it may have been sent;a native ETH contract (
pending_counter_contractwith no token): its deploy carries the value, and its address is written before that broadcast;a token contract that a push was signed for (
pending_push_nonce), that holds tokens, that is settled, or whose balance cannot be read: the token balance must read 0 and the contract unsettled at the finalized block and at the tip at every endpoint, and on a chain that carries value from more than one endpoint; and the key that would push (this leg’s key, when it created the contract) must be idle at every endpoint, since a push no record names may be in flight (design §2.7, with its residuals);a record of this swap on disk (the persist hook’s
load_record) that is pastNEGOTIATEDor holds a counter-leg locator, pending contract, BTC funding transaction or token push that this coordinator’s record does not. It is read underCoordinatorConfig.fund_lockwhen one is set, which the ETH fund holds across deploy and push (the BTC fund takes no lock, so a BTC fund in another process is not excluded);a BTC record that may have been written by pyrxd 0.26.x or earlier, unless confirm_btc_htlc_empty is
True: one that was read without arole(read_without_role, kept after a 0.27 coordinator stamps one), whose record on disk names noroleor carries that mark, or any BTC record when there is no persist hook that reads the record back (load_record). Those versions recorded no funding transaction before its broadcast (#841), so such a record reads as never funded even if a fund was broadcast and its readback failed, and this step cannot scan the HTLC address. PassTrueonly after checking that the HTLC address (BitcoinTaprootLeg.htlc_address(terms)) has never received an output.
Nothing is broadcast.
abort_reasonisTAKER_NEVER_FUNDS. The hashlock stays reserved in the seen-store, so thisHis never funded again. A token contract that was deployed stays on the record (pending_counter_contract).- Parameters:
confirm_btc_htlc_empty (bool)
- Return type:
SwapRecord
- property btc_leg
Transitional alias for
counter_leg(the chain-neutral counter leg).
- async close_push_nonce()[source]
Close the token push’s nonce, so an empty expired counter-leg contract can be shown to stay empty (#850 PR 7a, design §2.7, §3.3). Valid from BTC_LOCKED on a token counter leg; refused for a MAKER-role coordinator. Moves no value: a 0-value transfer from the funding key to itself.
What the chain shows is read first, and applied (an end already proven ends the record here). It sends only when the contract reads
EXPIRED_EMPTY_OPEN, at the funding key’s nonce at the finalized block, every endpoint agreeing; the hash and nonce are recorded (push_close_*) before the broadcast. Once a transaction at that nonce is final, the contract readsEXPIRED_EMPTY_CLOSEDandtaker_refund_btc()(orreconcile_counter_leg()) ends the record ABORTED withabort_reasonEXPIRED_EMPTY, unless a recorded claim holds it until t_rxd (D14). A recorded close is followed, not re-sent, unless it was dropped.Not from NEGOTIATED yet (design §3.3 lists it): a NEGOTIATED record has no locator to read, and its exit,
abandon_unfunded, is #850 PR 8. Not from PARAMS_MISMATCH, by design.- Return type:
SwapRecord
- 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_refund_covenant()[source]
The MAKER’s exit from a swap in which it never revealed
p(#850 PR 11, design §8, D10).Valid from NEGOTIATED (the taker never funded), BTC_LOCKED (the maker never reached BOTH_LOCKED) and PARAMS_MISMATCH (the maker’s own revalidation failed). What spent the covenant is read first (
RadiantCovenantLeg.covenant_spend()):refunded and buried: the record ends (ABORTED,
abort_reasoncovenant_refunded,asset_refund_txid), and nothing is sent;refunded, not yet buried: nothing is sent; call again;
claimed: raises (
pis public; see_apply_covenant_spend());unspent, or a read that cannot decide: the covenant’s CSV refund is sent, and the leg itself refuses a covenant that is not live or not yet mature (
NetworkError, retry at maturity). A refund sent to a covenant that turns out spent fails at the node, harmlessly, so one source that answers wrongly can delay this step, never stop it. The txid is recorded asasset_refund_txid, and the record stays where it is until a later call reads it buried.
Refused while the record holds a counter-leg claim:
pis then public, and refunding the covenant as well would take both legs. The covenant outpoint is pinned (and persisted) before the first refund, so the read that ends the record finds the output the refund spent.- 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()(TAKER role on ETH: counter leg only, #850). The watchtower (gravity.watch.decide) routes neither counter-chain’s taker here.
- async mutual_refund(*, resend_reverted_refund=False)[source]
Both legs refund after both timeouts elapse — the guaranteed-safe failure.
Valid from BOTH_LOCKED. Requires the full locator be retained. Async because the refunds broadcast on their chains.
ETH counter leg (#850 PR 6, design §3.3): what the chain shows is read FIRST (
_read_counter_disposition()), and the record moves only on that.MAKERrole: refused. The counter leg is the taker’s to refund and the covenant the maker’s; the maker’s own step ismaybe_refund_asset_on_maker_stall().A claim proven on chain moves the record to SECRET_REVEALED (
pis public: claim the covenant). Nothing is sent.A terminal answer from one endpoint on a chain that carries value raises
CounterLegSingleSourceAnswer. Nothing is sent.Settled at the tip but not yet final: nothing is sent; the record waits.
Not yet refunded: our counter-leg refund is sent, or the one already recorded is followed, by the same rules as
taker_refund_btc()(recorded before the broadcast, re-sent or replaced only as design §3.4 allows;resend_reverted_refundas there). The record stays BOTH_LOCKED: call this again to follow it. An empty token contract raisesNothingToRefundnaming the abandon (design N2). With the dispositionUNKNOWN, a refund the leg refuses raisesCounterLegDispositionUnknown.Refunded at the finalized block: a
TAKERrecord goes to MUTUAL_REFUND and the covenant is never touched (its CSV refund pays the maker, and is the maker’s to send). A single operator’s (roleNone) covenant refund is sent now, and only now, recorded asasset_refund_txid, and the record goes to MUTUAL_REFUND once it has returned.While a
Claimedlog that opensHis on record (counter_claim_tx) and the covenant’st_rxdhas not matured, neither terminal is taken and the covenant is not refunded (D14, N1):pmay be public, and the covenant claim comes first.
BTC counter leg that reads its disposition (#850 PR 9a, design §6; F-A on BTC), for a
TAKERor single-operator record: the same rules, read by_apply_btc_counter_disposition()and sent or followed by_btc_refund_on_the_read()(raw bytes recorded before the broadcast, re-sent unchanged, replaced by BIP125 afterREFUND_REPLACE_AFTER_S). A buried refund takes aTAKERrecord to MUTUAL_REFUND without touching the covenant; a single operator’s covenant refund is sent only then. The D14 hold applies as on ETH. The watchtower reads this refund as ours by its leaf (#850 PR 9b).A leg that cannot read its disposition (written before 0.27), or a
MAKERon BTC: unchanged, except thatMAKERon ETH is refused.TAKERrefunds the counter leg only and stays BOTH_LOCKED. Every other case attempts both refunds and goes to MUTUAL_REFUND only if both returned.- Parameters:
resend_reverted_refund (bool)
- 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, now_sampled_monotonic=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 the wall clock as a param, never reads it). REQUIRED for an ETH swap (step 3’s cross-clock gate), for any swap whose Radiant leg is on mainnet (step 5 bounds the elapsed depth with it and refuses without it), and whenever the policy carries a reorg-cost measurement (step 0). Only a BTC swap on test networks may omit it; step 5 then omits the time term of its bound.now_sampled_monotonicis thetime.monotonic()reading taken whennow_unix_swas (default: this call’s entry); step 5 advancesnow_unix_sby the monotonic time elapsed since then, measured AFTER its reads (seetaker_verify_asset_funding()). Async because binding (1) awaits the async indexer adapter (a sync gate would leak a truthy un-awaited coroutine = fail-OPEN, T7 plan D2).
- async reconcile_counter_leg()[source]
Bring the record up to what the chain shows about the counter leg (#850 PR 5a for ETH, design §3.3; PR 9a for BTC, design §6). No broadcast. Valid from BTC_LOCKED, PARAMS_MISMATCH and BOTH_LOCKED. A BTC leg is read by
_apply_btc_counter_disposition(), which keeps the D14 hold on a recorded claim.An unreadable, below-quorum or contradictory chain changes nothing and raises
NetworkErrorwith the reason; see_apply_counter_disposition()for the rest.- Return type:
SwapRecord
- async reconcile_covenant()[source]
Bring a MAKER’s record up to what spent its covenant (#850 PR 11). No broadcast.
Valid from NEGOTIATED, BTC_LOCKED, PARAMS_MISMATCH and BOTH_LOCKED: the states in which the maker has not revealed
p. A buried refund of the covenant ends the record (see_apply_covenant_spend()); a refund not yet buried, or a covenant still unspent, changes nothing. A claim of the covenant raises. A read that cannot decide raisesNetworkErrorwith the reason, the record unchanged. Needs the covenant outpoint pinned on the record.- Return type:
SwapRecord
- 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 for a different swap (another hashlock) → refuse. For the same swap the RECORD’s terms are used, not the supplied ones: taker_funds_btc acts on the terms it is given and a runner rebuilds them from its arguments each run, so a drifted argument must neither fund one thing while the record describes another nor refuse a legitimate resume.
- 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, passed topre_btc_lock_check()and to the lock-time re-run oftaker_verify_asset_funding()— REQUIRED for an ETH swap (the cross-clock timelock-ordering gate, audit HIGH-1) and for any swap whose Radiant leg is on mainnet (the taker gate’s elapsed-depth bound); seepre_btc_lock_check().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). Persistence is a no-op when nopersisthook is injected — which is refused for a value-bearing BTC fund (below).A BTC fund records its signed funding transaction (
pending_btc_funding_tx) BEFORE the broadcast.fundbroadcasts and then reads the amount back, and that read can fail after the broadcast succeeded; without the bytes the record stayed NEGOTIATED with no locator and H reserved, so a retry was refused as “H reused” and nothing in-band could reach the BTC. A call on a record carrying them is a RESUME: if the recorded output is already confirmed it is recorded as the lock with no gate (the funds already moved — refusing would only hide them); otherwise the full gate runs as for a fresh fund and, if it passes, the SAME bytes are re-sent (they spend one specific UTXO, so they cannot fund twice).- 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(*, resend_reverted_refund=False)[source]
Refund the counter leg via its timelock leg; the record ends in ABORTED (on ETH, once the chain shows the refund final, below).
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.
ETH counter leg (#850 PRs 5a and 5b, design §3.3, §3.4): what the chain shows is read first, and the record moves only on that. Our own refund is RECORDED (hash and nonce, before the broadcast) and the record stays where it is until the chain shows the contract refunded at the finalized block; call this again (the runner polls) to follow it. A recorded refund is not re-sent blindly: a retry reads what happened to it and re-sends only when it was dropped, its nonce went to another transaction, or it ran out of gas with less than the current estimate. A pending one is replaced at the same nonce after
REFUND_REPLACE_AFTER_S, priced over whichever refund of ours at that nonce the node holds; a pending transaction that is none of ours is never replaced (CounterLegRefundNeedsOperator). A mined refund that reverted is not re-sent:CounterLegPayoutRevertswhen the payout is what fails, elseCounterLegRefundNeedsOperator; after looking, the operator passesresend_reverted_refund=Trueto send one more.BTC counter leg (#850 PR 9a, design §6), when the leg reads its disposition (
BitcoinTaprootLeg.counter_disposition): the same shape. What the chain shows is read first (_apply_btc_counter_disposition()); the record goes to ABORTED only once a refund is buried at the quorum’s depth, and a buried verified claim takes it to SECRET_REVEALED. Our refund’s raw bytes are recorded (counter_refund_raw) before the broadcast; a retry re-sends those bytes, or afterREFUND_REPLACE_AFTER_Sin the mempool replaces them by BIP125 (_btc_refund_on_the_read()).MAKERrole is refused;resend_reverted_refundhas no meaning on BTC. A BTC leg that reads no disposition keeps the old path: refund, then ABORTED on the broadcast.- Parameters:
resend_reverted_refund (bool)
- 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, *, now_unix_s=None, now_sampled_monotonic=None)[source]
Fail-closed: the MAKER’s asset must be PROVED locked on chain before the taker locks anything.
Returns
(outpoint, value_photons, elapsed_blocks_upper); RAISES on anything else. The third value is an UPPER bound on the blocks mined since the funding (for the timelock gates that subtract it fromt_rxd); the full record of what was proved —k,C, the proved depth — is left onlast_maker_funding.THE FUNNEL. Every path that locks the taker’s counter leg crosses this method before
counter_leg.fundruns:pre_btc_lock_check()step 5, and the re-run insidetaker_funds_btc()immediately before the broadcast (which closes the verify->lock TOCTOU: a maker double-spending its covenant funding away between check and lock), on the BTC and the ETH/ERC-20 branch alike;resume_interrupted_fund()completes a crashed fund throughtaker_funds_btc(), so it crosses both.tests/test_taker_funding_spv_gate.pyderives the set offundcall sites from this module and checks each is preceded by it.WHY. HZ-1 in
docs/htlc-handshake-wire-format.mdstates it as a normative MUST: the maker holdspand 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 taker that locks against a covenant that does not exist loses the whole counter leg. Until this gate, “exists” meant one ElectrumX server’slistunspentsaid so — and a server that invented the covenant made this coordinator lock.WHAT IT DOES NOW. The Radiant leg fetches EVIDENCE (
maker_funding_evidence: the raw funding tx, its merkle and coinbase branches, and the header rangesfunding_header_ranges()plans), and this method PROVES it withverify_maker_funding(): the covenant scriptPubKey — re-derived here from the taker’s OWN terms via the leg — and the value are read from the funding transaction’s raw bytes, inclusion is proved against a header linked to a shipped checkpoint, and the depth must reachk = max(6, burial, ceil(2 × value ÷ C))(the maintainer’s rule;Cis the photon cost of one forged confirmation, from Radiant’s own subsidy schedule and the proved headers). On regtest the same proof runs against the network’s genesis, with no value term. A leg that cannot supply evidence refuses — there is no path that proceeds on the server’s word.What remains the server’s word: that the covenant output is still UNSPENT (SPV cannot show a non-spend). The elapsed-depth upper bound is statistical — a confidence scaled by the value, at
funding_bound.surge_factortimes the nominal block rate — aspyrxd.gravity.funding_spvstates.THE REFERENCE TIME IS TAKEN AFTER THE READS. The gate’s time term counts the blocks mined between the reference header’s median time past and “now”; a “now” read before a slow fetch is earlier than the moment the bound is used, and would count fewer. So the
nowthe gate judges isnow_unix_sADVANCED by the monotonic time elapsed fromnow_sampled_monotonic(whennow_unix_swas taken; default: this call’s entry) to the end of the reads, rounded up to a whole second. The wall clock is still only the caller’s; a slow read can only make the bound larger.
- last_maker_funding: VerifiedMakerFunding | None
What the taker gate last PROVED about the maker’s funding (k, C, depths), or None.
- 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, single_operator_override=None, fund_refusal=None, pending_btc_funding_tx=None, role=None, counter_claim_tx=None, counter_disposition=None, abort_reason=None, asset_refund_txid=None, counter_refund_tx_hash=None, counter_refund_nonce=None, counter_refund_replaced_tx_hashes=(), counter_refund_raw=None, counter_expiry_anchor=None, push_close_tx_hash=None, push_close_nonce=None, push_signer=None, abandon_case=None, abort_note_sha256=None, read_without_role=False)
- 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)
single_operator_override (str | None)
fund_refusal (str | None)
pending_btc_funding_tx (str | None)
role (RecordRole | None)
counter_claim_tx (str | None)
counter_disposition (CounterDispositionRecord | None)
abort_reason (AbortReason | None)
asset_refund_txid (str | None)
counter_refund_tx_hash (str | None)
counter_refund_nonce (int | None)
counter_refund_raw (str | None)
counter_expiry_anchor (str | None)
push_close_tx_hash (str | None)
push_close_nonce (int | None)
push_signer (str | None)
abandon_case (AbandonCase | None)
abort_note_sha256 (str | None)
read_without_role (bool)
- Return type:
None
- abandon_case: AbandonCase | None = None
the §3.6 case the abandon was accepted on, and the SHA-256 (lower-case hex) of the sidecar file holding the operator’s note (design D18), so the record itself carries no free text.
- Type:
Set together with
abort_reasonOPERATOR_ABANDONEDand only with it (#850 PR 7b)
- abort_reason: AbortReason | None = None
Why the record went to ABORTED when no broadcast of ours took it there (
AbortReason).
- asset_refund_txid: str | None = None
The covenant’s CSV refund this process broadcast in
mutual_refund(Radiant txid hex), written the moment the broadcast returns. A retry does not send it again: the covenant is spent, so re-sending fails, and the retry never completed (#850 PR 5a).
- 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.
- counter_claim_tx: str | None = None
A
Claimed(p)transaction on the counter-leg contract, seen in its logs but NOT verified (#850 PR 5a, design §2.3 CLAIM_SEEN). Liveness only:pis public wherever that transaction is, so the covenant claim is due; it proves nothing about the contract. While it is set and t_rxd has not matured, an observed refund does not take the record terminal (D14).
- counter_disposition: CounterDispositionRecord | None = None
The verified chain evidence behind the last counter-leg transition this record took (
CounterDispositionRecord), or one recorded where no edge exists. Serialised only when set.
- counter_expiry_anchor: str | None = None
"<number>:0x<hash>"of a finalized block at which the counter-leg contract was unsettled and at or past its timeout, from a quorum read (design §2.3F_EXPIRED). From then on no claim can succeed, so a later settlement is a refund; the hash is rechecked canonical at use.
- counter_refund_raw: str | None = None
the signed transaction, hex, written between signing and broadcasting, so a crash after the send cannot lose it and a re-send uses the same bytes. An RBF bump re-signs at a higher fee and overwrites it. Its txid is derived from the bytes. Not evidence of anything: only the chain read is.
- Type:
Our own refund of the BTC counter leg (#850 PR 9a, design §6)
- counter_refund_replaced_tx_hashes: tuple[str, ...] = ()
a replacement may lose to the one it replaced, or never reach the node, so what holds the nonce can be any of them. Cleared when a refund is signed at another nonce; at most
MAX_REPLACED_REFUNDSkept.- Type:
Refunds we signed EARLIER at that same nonce, newest first
- counter_refund_tx_hash: str | None = None
its hash and nonce, written between signing and broadcasting, so a crash after the send cannot lose it and a retry reads what happened to it instead of sending another. A replacement at the same nonce overwrites the hash. Not evidence of anything: only the chain read (
counter_disposition) is.- Type:
Our own refund of the ETH counter leg (#850 PR 5b, design §2.8)
- counterchain_locator: BtcHtlcLocator | EthHtlcLocator | None = None
- fund_refusal: str | None = None
Why the taker’s coordinator REFUSED to complete an interrupted fund whose counter-leg contract was already deployed (
pyrxd.gravity.swap_coordinator.SwapCoordinator.resume_interrupted_fund()), orNone. Set with the move toBTC_LOCKEDwhen that contract may hold value, so the watchtower tracks its refund (and a maker’s claim withp) instead of reading a NEGOTIATED record as “nothing locked”. Serialised only when set.
- pending_btc_funding_tx: str | None = None
The taker’s BTC funding transaction (raw signed hex), recorded BEFORE it is broadcast and cleared once the funded locator is attached. The HTLC address is derivable from the terms, but the funding transaction is not, and BitcoinTaprootLeg.fund broadcasts first and reads the amount back second: a readback that times out or fails left real BTC on chain under a NEGOTIATED record with no locator and H already reserved, and every in-band path (retry, taker_refund_btc, mutual_refund) refused. With the bytes on the record a resume can find the output on chain, or re-broadcast the SAME bytes — which cannot fund twice, because the transaction spends one specific UTXO. The txid is derived from these bytes, never stored beside them, so the two cannot disagree. Serialised only when set.
- property pending_btc_funding_txid: str | None
The txid of
pending_btc_funding_tx, derived from its bytes (orNone).
- 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.
- push_close_tx_hash: str | None = None
The 0-value self-transfer that closes the token push’s nonce (#850 PR 7a, design §2.7,
SwapCoordinator.close_push_nonce): its hash and nonce, written between signing and broadcasting. Once a transaction at this nonce is final, no push this key signed at or below it can land, so an empty expired contract stays empty. Not evidence: the chain read is.
- push_signer: str | None = None
The address (lower-case) of the key that signs the token push at
pending_push_nonce, written with the nonce before the push (#850 PR 7a). The nonce closed byclose_push_nonceand the count read forEXPIRED_EMPTY_CLOSEDare this key’s; a resume with another key is refused.Noneon a record written before it was kept.
- read_without_role: bool = False
This record was once read with no
role(#850 PR 8), so it may have been written by pyrxd 0.26.x or earlier, which recorded no BTC funding transaction before its broadcast (#841).from_dictsets it on a record without a role and keeps it once written, andJsonFileRecordSinksets it on a write over a record on disk without a role, so a role stamped later by a 0.27 coordinator does not erase what the missing role said. Written only with a role: a record without one says the same thing by itself.compare=False: a record without a role reads back with the mark, and is still the same record.
- role: RecordRole | None = None
Which side wrote this record (
RecordRole). The coordinator stamps it in memory when it takes a record in (its constructor,resume_interrupted_fund) and refuses a record naming another role;JsonFileRecordSinkkeeps a persisted role and refuses a write naming another; the two-host runners refuse a phase run as the other side.Noneon a record from before 0.27 (“unknown”). Serialised only when set.
- single_operator_override: str | None = None
The taker gate’s statement that a USER OVERRIDE set the value up to which a depth reported by a single operator was accepted (
ElapsedBoundPolicy.single_operator_override_statement: “single-operator depth accepted up to X RXD by user override (default Y RXD)”), orNonewhen no override was set. Written by the coordinator when the gate runs; serialised only when set, so a record without it keeps its existing wire form.
- 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.KEEPS the token push’s nonce and hash (#850 PR 3). They are not a pending handle: the nonce is the slot every push for this swap used, which is what later proves no other push can land (design §2.7, closing the push nonce). The hash is the LAST push signed at that slot, which need not be the one that mined (a replacement can lose to the original, or fail to broadcast). Today only a resume reads it, for the pending push’s fees, and refuses when it cannot be read back; anything that later relies on it as THE push must check it against a receipt first. Keeps
role: it is not about the fund.- 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.client must be able to prove which chain it is on: its server is asked (
assert_chain) before anything is funded — see_require_client_on_chain().- Raises:
ValidationError – plan is not a
MarkPlan, or client’s server is not on the chain the plan was signed for, or client cannot be asked, 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.
NetworkError – the server could not be reached to ask which chain it is on.
- 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 (
wrap_cek_x25519()), bound to thecrypto.cek_hashstring as AAD (cek_wrap_aad())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, allow_unknown_genesis=False)[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', allow_unknown_genesis=False)[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'', *, allow_legacy_info=True)[source]
Recover a CEK wrapped via
wrap_cek_x25519()(or Photonic’swrapCEKwith the non-PQ X25519 path).Tries
KEK_DERIVATION_INFOfirst and, if the AEAD tag fails, retries once withLEGACY_KEK_DERIVATION_INFO— the string pyrxd emitted from v0.6.0 to 0.24.0, before Photonic split classical from hybrid. Without that retry, content pyrxd itself encrypted in that window becomes permanently unreadable, which is a worse outcome than accepting a second known-good derivation. Passallow_legacy_info=Falseto require the current spelling.THE RETRY IS NOT A DOWNGRADE HOLE. Both values are fixed constants, not attacker-chosen; the AEAD tag still has to verify under whichever one is tried; and neither corresponds to Photonic’s hybrid mode, so a stripped ML-KEM ciphertext still fails here exactly as upstream intends. What it changes is only which of two pyrxd-era spellings is accepted.
Use
unwrap_cek_x25519_detailed()when the caller needs to know which derivation succeeded — for example to re-wrap legacy content before the fallback is retired.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. For a Glyph recipient slot, Photonic’s app binds the UTF-8 bytes of the on-chaincrypto.cek_hashSTRING ("sha256:<hex>", 71 bytes), not the 32-byte digest; build it withpyrxd.glyph.timelock.cek_wrap_aad. No REP fixes this value — REP-3006 defines AAD only for the content AEAD — so the wallet is the reference.