pyrxd.glyph — Glyph token protocol¶
Glyph protocol — NFT singletons, FT tokens, dMint contracts, mutable refs.
Re-exports the public Glyph API from the submodules. Lazy via PEP 562
__getattr__ so import pyrxd.glyph.X paths that don’t need the
full builder/signing chain (e.g. pyrxd.glyph.inspect from the
browser-hosted inspect tool) avoid pulling in coincurve,
aiohttp, Cryptodome.Cipher, etc. transitively.
See pyrxd for the broader rationale on lazy public re-exports.
- class pyrxd.glyph.AirdropFunding[source]¶
Bases:
objectA plain-P2PKH RXD UTXO that pays an airdrop’s fee (and any royalty).
The token cannot pay its own fee: an FT output’s value is its unit count, so taking the fee out of one would burn units and deliver less than the caller asked for. Plain RXD covers it instead — the same reason
transfer-nftsources a separate input to move a dust-carrying singleton.Each funding UTXO carries its own key, so the RXD may sit at a different wallet address from the token. (The FT inputs themselves still share one key; that restriction is inherited from
FtUtxoSet.build_transfer_tx().)- Parameters:
txid – txid of the plain-P2PKH UTXO
vout – output index within that tx
value – photons available. Must be a positive
int, checked here for the same reasonFtUtxochecks its own: this number is summed into the RXD budget the fee comes out of, and a wrong one used to be noticed only much later and unhelpfully — measured as'float' object has no attribute 'to_bytes'from the middle of output serialisation for1_000_000.5,OverflowErrorfor a negative, and forTruea nonsense “budget 1 photons” in the funding error.private_key –
pyrxd.keys.PrivateKeythat unlocks it
- Raises:
ValidationError –
valueis not a positiveint.
- __init__(txid, vout, value, private_key)¶
- class pyrxd.glyph.AirdropReceipt[source]¶
Bases:
objectWhat a broadcast airdrop actually did — the multi-recipient
TransferReceipt.Carries
recipientsin output order as well astotal, because after the fact those are two different questions: “how much left the wallet” is reconcilable from the total, while “who got what, at which vout” is only answerable from the ordered list, and re-deriving it means re-fetching and re-parsing the transaction.- fee¶
- recipients¶
- ref¶
- total¶
- txid¶
- class pyrxd.glyph.AirdropRecipient[source]¶
Bases:
objectOne destination in a multi-recipient FT airdrop.
- Parameters:
pkh – recipient’s 20-byte public-key hash.
amount – FT units for this recipient, and — because 1 photon is 1 unit on Radiant — the exact photon value of their output. Must be
> 0.
- exception pyrxd.glyph.BroadcastEchoMismatch[source]¶
Bases:
RxdSdkErrorThe server’s txid did not match the transaction we signed.
Deliberately NOT a
ValidationError. Those are raised before anything is SENT —transfer_nft’s is raised after signing but before broadcast — whereas this one can only happen after the broadcast, when the transaction may well have relayed. A caller withexcept ValidationError: retrywould re-broadcast a transfer that already moved tokens.Carries
local_txidso the caller can check the chain for what was actually sent.
- class pyrxd.glyph.ChainStep[source]¶
Bases:
objectOne transaction in the singleton’s own history.
- __init__(txid, mut_vout, kind, attrs=<factory>, reason='')¶
- class pyrxd.glyph.ContainerChildRevealScripts[source]¶
Bases:
objectScripts for revealing a token as a member of a container.
See
GlyphBuilder.prepare_container_child_reveal()for the two-input / two-output reveal shape these must be placed in.- __init__(ref, nft_script, container_script, scriptsig_suffix, container_ref)¶
- class pyrxd.glyph.ContainerRevealScripts[source]¶
Bases:
objectScripts for a CONTAINER reveal.
locking_scriptis the plain 63-byte NFT singleton — a container has no distinct script shape (seeGlyphBuilder.prepare_container_reveal()).- __init__(ref, locking_script, scriptsig_suffix, child_ref=None)¶
- class pyrxd.glyph.DaaMode[source]¶
Bases:
IntEnum- __new__(value)¶
- FIXED = 0¶
- EPOCH = 1¶
- ASERT = 2¶
- LWMA = 3¶
- SCHEDULE = 4¶
- class pyrxd.glyph.DmintAlgo[source]¶
Bases:
IntEnum- __new__(value)¶
- SHA256D = 0¶
- BLAKE3 = 1¶
- K12 = 2¶
- class pyrxd.glyph.DmintCborPayload[source]¶
Bases:
objectThe
dmintobject embedded in Glyph V2 token metadata CBOR.Indexers read this to discover dMint contracts and display mining parameters in wallets/explorers without parsing the contract script.
Field names mirror Photonic Wallet
DmintPayloadtype in types.ts.- __init__(algo, num_contracts, max_height, reward, premine, diff, daa_mode=DaaMode.FIXED, target_block_time=60, half_life=0, window_size=0)¶
- class pyrxd.glyph.DmintDeployParams[source]¶
Bases:
objectParameters for deploying a V2 dMint contract.
- __init__(contract_ref, token_ref, max_height, reward, difficulty, algo=DmintAlgo.SHA256D, daa_mode=DaaMode.FIXED, target_time=60, half_life=3600, height=0, last_time=0, epoch_length=2016, max_adjustment_log2=2, schedule=())¶
- Parameters:
- Return type:
None
- class pyrxd.glyph.DmintMineResult[source]¶
Bases:
objectThe output of a successful
mine_solution()call.- Parameters:
nonce – The nonce bytes (4B for V1, 8B for V2) that satisfy the target.
attempts – Number of nonce candidates tried before finding the solution.
elapsed_s – Wall-clock seconds spent searching.
- __init__(nonce, attempts, elapsed_s)¶
- class pyrxd.glyph.DmintMintResult[source]¶
Bases:
objectOutput of
build_dmint_mint_tx().- Parameters:
tx – Unsigned transaction (caller must sign).
updated_state – New
DmintStatewritten into the contract output (height incremented, target updated if DAA is active).contract_script – New contract output script (state + separator + code).
reward_script – P2PKH locking script of the miner reward output.
fee – Transaction fee in photons.
Note
The transaction returned here is unsigned — it uses raw script bytes for the contract input’s unlocking script (nonce + preimage halves) built by
build_mint_scriptsig(). The contract script is a covenant, not a P2PKH, so standardTransaction.sign()is not appropriate. The caller must either set the unlocking script directly or use a custom signing path. See docstring ofbuild_dmint_mint_tx()for details.- __init__(tx, updated_state, contract_script, reward_script, fee)¶
- class pyrxd.glyph.DmintState[source]¶
Bases:
objectParsed dMint contract state (from on-chain UTXO script).
Supports both V1 (the current Radiant mainnet format) and V2 (Photonic Wallet’s HEAD spec, not yet seen on mainnet). V1 has 6 state items; V2 has 10.
is_v1is True iff this state was parsed from V1 layout — in which casetarget_timeandlast_timeare not meaningful on-chain values and are set to 0;daa_modeis alwaysFIXEDfor V1 (the V1 contract template has no DAA bytecode).- __init__(height, contract_ref, token_ref, max_height, reward, algo, daa_mode, target_time, last_time, target, is_v1=False)¶
- classmethod from_script(script_bytes)[source]¶
Parse a dMint contract UTXO script into a
DmintState.Tries V2 layout first (10 state items), falls back to V1 (6 items + fingerprinted code epilogue). Raises
ValidationErrorif the script matches neither.- Parameters:
script_bytes (bytes) – Raw script bytes from a dMint contract UTXO output.
- Raises:
ValidationError – Script is malformed or matches neither V1 nor V2 layout.
- Return type:
- class pyrxd.glyph.DmintV1ContractInitialState[source]¶
Bases:
objectJust-deployed state of a V1 dMint contract template.
Carries exactly the parameters needed to reconstruct the initial (height=0) contract codescript for every contract of a given deploy. Used by
find_dmint_contract_utxos()’s fast path, where the caller already knows the deploy params.- Parameters:
num_contracts – Count of parallel contracts the deploy created (1..255 for V1; mainnet GLYPH used 32).
reward_sats – Photons emitted per successful mint (must fit in 3 bytes — V1 protocol constant).
max_height – Maximum mints per contract (3-byte ceiling).
target – 8-byte SHA256d PoW target.
algo – PoW algorithm. Defaults to
DmintAlgo.SHA256D, which is the only algorithm seen on V1 mainnet.
- __init__(num_contracts, reward_sats, max_height, target, algo=DmintAlgo.SHA256D)¶
- class pyrxd.glyph.DmintV1DeployParams[source]¶
Bases:
objectParameters for a V1 dMint deploy (2-tx: commit + reveal).
V1 is the only dMint format on Radiant mainnet today. Unlike V2 (which uses a separate deploy tx with a reward pool), V1 emits
num_contractsparallel singleton contract UTXOs directly in the reveal — each is the full state+epilogue codescript at height=0. Mining works by spending a contract UTXO and re-creating it at height+1 with the same script template; the reward is paid from a miner-supplied funding input.See
docs/dmint-research-photonic-deploy.mdfor the byte-by-byte chain shape this dataclass drives. Live mainnet example: Radiant Glyph Protocol (GLYPH) at commit a443d9df…878b → reveal b965b32d…9dd6.- Parameters:
metadata –
GlyphMetadatafor the token. Must include protocol[GlyphProtocol.FT, GlyphProtocol.DMINT]([1, 4]) and NOT include avversion field (V2 usesv; V1 omits it).owner_pkh – 20-byte PKH of the key that signs commit and all ref-seed P2PKH inputs in the reveal.
num_contracts – Count of parallel V1 dMint contract UTXOs to emit. Total supply =
reward_photons * max_height * num_contracts. Validated to[1, 250]at construction. 250 is a pyrxd ERGONOMICS ceiling, not a node limit: at ≈ 241 bytes/contract output it keeps the reveal near ~64 KB before the embedded media body. Radiant’s ownMAX_STANDARD_TX_SIZEis 20_000_000 bytes (Radiant-Core/src/policy/policy.h:69@ v3.1.2) and is never even consulted, sincefRequireStandardis hardcodedfalse(src/validation.cpp:271,src/init.cpp:1995@ v3.1.2). What actually bounds this is fee: every contract output costs ~241 bytes × the 10_000 photons/byte relay floor.max_height – Maximum mints per contract (3-byte ceiling).
reward_photons – Photons paid per successful mint (3-byte ceiling — see V1 contract state layout).
difficulty – Initial PoW difficulty (1 = easiest). Translated to 8-byte target via
difficulty_to_target().premine_amount – Photons emitted as an additional FT output on the reveal tx (1 photon = 1 FT unit), on top of the mineable supply.
None= no premine. The photons are real: the deployer must fund them, and they are NOT deducted fromreward_photons * max_height * num_contracts— total issued supply becomesreward_photons * max_height * num_contracts + premine_amount. Mirrors Photonic WalletRevealDmintParams.premine(mint.tscreateRevealOutputs), which likewise appends oneftScriptoutput after the contract outputs.premine_pkh – 20-byte PKH that receives the premine output.
None(default) sends it toowner_pkh, which is what Photonic does (it uses the single creator address for both). Only meaningful whenpremine_amountis set.op_return_msg – Optional OP_RETURN data carrier (raw bytes after the 0x6a prefix).
None= no OP_RETURN output.algo – PoW algorithm. Defaults to
DmintAlgo.SHA256D(the only algorithm on V1 mainnet today).
- __init__(metadata, owner_pkh, num_contracts, max_height, reward_photons, difficulty, premine_amount=None, op_return_msg=None, algo=DmintAlgo.SHA256D, premine_pkh=None)¶
- metadata: GlyphMetadata¶
- class pyrxd.glyph.DmintV1DeployResult[source]¶
Bases:
objectOutput of
GlyphBuilder.prepare_dmint_deploy()for V1 deploys.Carries everything the caller needs to broadcast a V1 deploy: the commit-tx script + CBOR body, plus a deferred-builder method that produces the reveal-tx outputs once the commit confirms.
V1 differs from V2 in that there is no separate deploy tx — the reveal directly creates the parallel contract UTXOs. So this result has no
deploy_params_template/initial_pool_photons/placeholder_contract_scriptfields; instead it carriesplaceholder_contract_scripts(one per parallel contract) for fee estimation before the commit txid is known.- Parameters:
commit_result –
CommitResult— commit-tx script + fee. Same shape as the V2 result’s field.cbor_bytes – Encoded CBOR token body.
owner_pkh – 20-byte PKH of the deploy key.
premine_amount – Photons for the optional premine output, or
Nonefor no premine.num_contracts – Count of parallel V1 contracts.
placeholder_contract_scripts – Tuple of N contract scripts built with the placeholder commit txid (00…00). Each is the same byte length as the final contract script — the only difference is the
contractRef/tokenReftxid component. Use the length for fee estimation.max_height – Echoed from params for
build_reveal_outputsaccess.reward_photons – Echoed from params.
difficulty – Echoed from params.
algo – Echoed from params.
op_return_msg – Echoed from params.
premine_pkh – Echoed from params;
Nonemeans the premine (if any) goes toowner_pkh.
- __init__(commit_result, cbor_bytes, owner_pkh, premine_amount, num_contracts, placeholder_contract_scripts, max_height, reward_photons, difficulty, algo, op_return_msg, premine_pkh=None)¶
- Parameters:
- Return type:
None
- build_reveal_outputs(commit_txid)[source]¶
Build reveal-tx output scripts given the confirmed commit txid.
The V1 reveal:
spends commit vouts 0 (FT-commit hashlock) + 1..N (ref-seeds) + change
emits N parallel dMint contract UTXOs at vouts 0..N-1
emits the optional premine FT output, then the optional OP_RETURN, then change — see
DmintV1RevealScriptsfor the ordering rule
The method name is
build_reveal_outputs(notbuild_reveal_scriptsas in V2) because V1’s reveal directly creates the output contract UTXOs — there is no separate deploy tx. The arity also differs from V2’s (no commit_vout / commit_value needed: V1 input values are protocol constants). Distinct names prevent silent polymorphic-call TypeErrors.- Parameters:
commit_txid (str) – txid of the confirmed commit tx.
- Returns:
DmintV1RevealScriptsready to be placed into the reveal tx’s outputs.- Return type:
- commit_result: CommitResult¶
- class pyrxd.glyph.DmintV1RevealScripts[source]¶
Bases:
objectOutput scripts for the V1 dMint deploy reveal tx.
Mirrors the shape of
FtDeployRevealScripts(a flat locking-script + scriptsig-suffix bag), but with V1’s distinctive multi-output structure: N contract scripts + optional premine FT + optional OP_RETURN. The caller composes these into a transaction in declared order, signs each input, and broadcasts.Output order is part of the contract with this bag. Place them as:
vout[0 .. N-1] contract_scripts, each valued contract_value (1) vout[N] premine_script, valued premine_amount (if any) vout[N+1] op_return_script, valued 0 (if any) vout[...] change
which is what Photonic Wallet’s
createRevealOutputsemits (mint.ts: thepremine > 0ftScriptpush comes directly after thenumContractsdMintScriptpushes). Nothing in consensus reads the ordering — the reveal runs only the commit hashlock, whoseOP_REFTYPE_OUTPUTcheck is position-independent — but indexers key off it, so deviating makes a token that pyrxd can spend and other tools cannot classify.Safety note on the premine script shape: it is an FT lock, so it pushes
tokenRefwithOP_PUSHINPUTREF(0xd0, refType NORMAL). The commit hashlock the reveal spends assertsOP_REFTYPE_OUTPUT == OP_1(NORMAL) for exactly this ref. Emitting the premine as an NFT/singleton lock (0xd8) would flip that to SINGLETON and the reveal would be rejected.- Parameters:
contract_scripts – Tuple of full V1 dMint contract output scripts (state + epilogue), one per parallel contract. Length equals the deploy’s
num_contracts. Each is the 241-byte layout at height=0 withcontractRef[i] = (commit_txid, i+1)andtokenRef = (commit_txid, 0).contract_value – Photons per contract output. Always 1 (V1 contracts are singletons — the photon value stays at 1 as the contract advances).
cbor_bytes – Encoded CBOR token body. Caller pushes this in the reveal’s vin[0] scriptSig (after sig + pubkey), preceded by the
glymagic bytes push.scriptsig_suffix – The push sequence
<gly> <CBOR>ready to append after<sig> <pubkey>for vin[0]. Mirrors theFtDeployRevealScripts.scriptsig_suffixconvention.premine_script – 75-byte FT locking script for the optional premine output, bound to
tokenRef(None= no premine).premine_amount – Photons for the premine output (
Noneif no premine). Set it as that output’s value verbatim.op_return_script – Locking script for an optional OP_RETURN data carrier (
Noneif no OP_RETURN).
- __init__(contract_scripts, contract_value, cbor_bytes, scriptsig_suffix, premine_script, premine_amount, op_return_script)¶
- class pyrxd.glyph.DmintV2DeployParams[source]¶
Bases:
objectParameters for a V2 dMint token deploy (2-tx: commit + reveal).
Mirrors
DmintV1DeployParams. V2 emitsnum_contractsparallel 1-photon singleton contract UTXOs directly in the reveal —contractRef[i] = commit:(i+1),tokenRef = commit:0— exactly like V1. The only consensus differences are the V2 contract bytecode (10-item state + the V2 covenant) and the 8-byte mint nonce; the reward + tx fee for each mint come from a miner-supplied funding input, not a pool.All five
DaaModevalues are supported — FIXED, ASERT, LWMA, EPOCH, and SCHEDULE — and the redesigned covenant advancestarget/last_timeon-chain, byte-matched to canonical PhotonicdMintScript(incl. the EPOCH/LWMA int64-overflow fix, Radiant-Core/Photonic-Wallet#2). See #219.V2 is consensus-proven on regtest + mainnet but still pre-external-audit;
prepare_dmint_deploydeploys V2 by default as of 0.9.0 (allow_v2_deploydefaults toTrueand is retained only for backward-compatibility).- Parameters:
metadata –
GlyphMetadata(must includeGlyphProtocol.FTandGlyphProtocol.DMINT; setversion=2so indexers classify it as V2).owner_pkh – 20-byte PKH of the key that signs commit + the ref-seed reveal inputs.
num_contracts – Count of parallel V2 contract UTXOs (
[1, 250]).max_height – Maximum mints per contract.
reward_photons – Photons paid per successful mint.
difficulty – Initial PoW difficulty (1 = easiest).
premine_amount – Photons emitted as an extra FT output on the reveal, on top of the mineable supply (mirrors V1 — see
DmintV1DeployParams). Ifmetadatacarries admint.preminefield, the two must agree or the deploy is refused.premine_pkh – PKH receiving the premine;
None=owner_pkh.op_return_msg – Optional OP_RETURN data carrier (raw bytes after 0x6a).
algo – PoW algorithm (default SHA256d; only SHA256D is mined).
daa_mode – Must be
DaaMode.FIXED(the only mintable mode).target_time – Echoed into the state (DAA-only; vestigial for FIXED).
half_life – Echoed into the code (DAA-only; vestigial for FIXED).
- __init__(metadata, owner_pkh, num_contracts, max_height, reward_photons, difficulty, premine_amount=None, op_return_msg=None, algo=DmintAlgo.SHA256D, daa_mode=DaaMode.FIXED, target_time=60, half_life=3600, epoch_length=2016, max_adjustment_log2=2, schedule=(), premine_pkh=None)¶
- Parameters:
metadata (GlyphMetadata)
owner_pkh (Hex20)
num_contracts (int)
max_height (int)
reward_photons (int)
difficulty (int)
premine_amount (int | None)
op_return_msg (bytes | None)
algo (DmintAlgo)
daa_mode (DaaMode)
target_time (int)
half_life (int)
epoch_length (int)
max_adjustment_log2 (int)
premine_pkh (Hex20 | None)
- Return type:
None
- metadata: GlyphMetadata¶
- class pyrxd.glyph.DmintV2DeployResult[source]¶
Bases:
objectOutput of
GlyphBuilder.prepare_dmint_deploy()for V2 deploys.Mirrors
DmintV1DeployResult: V2 emitsnum_contractsparallel 1-photon singleton contract UTXOs directly in the reveal (no separate deploy tx, no reward pool). Callbuild_reveal_outputs()once the commit confirms to get the reveal-tx output scripts.- Parameters:
commit_result –
CommitResult— commit-tx script + fee.cbor_bytes – Encoded CBOR token body.
owner_pkh – 20-byte PKH of the deploy key.
premine_amount – Photons for the optional premine output, or
Nonefor no premine (mirrors V1).num_contracts – Count of parallel V2 contracts.
placeholder_contract_scripts – Tuple of N V2 contract scripts built with the placeholder commit txid (00…00) — same byte length as the final scripts, for fee estimation before the commit txid is known.
- :param max_height, reward_photons, difficulty, algo, op_return_msg, daa_mode,
target_time, half_life: Echoed from params for
build_reveal_outputs().
- __init__(commit_result, cbor_bytes, owner_pkh, premine_amount, num_contracts, placeholder_contract_scripts, max_height, reward_photons, difficulty, algo, op_return_msg, daa_mode, target_time, half_life, epoch_length=2016, max_adjustment_log2=2, schedule=(), premine_pkh=None)¶
- Parameters:
commit_result (CommitResult)
cbor_bytes (bytes)
owner_pkh (Hex20)
premine_amount (int | None)
num_contracts (int)
max_height (int)
reward_photons (int)
difficulty (int)
algo (DmintAlgo)
op_return_msg (bytes | None)
daa_mode (DaaMode)
target_time (int)
half_life (int)
epoch_length (int)
max_adjustment_log2 (int)
premine_pkh (Hex20 | None)
- Return type:
None
- build_reveal_outputs(commit_txid)[source]¶
Build reveal-tx output scripts given the confirmed commit txid.
Mirrors
DmintV1DeployResult.build_reveal_outputs(): emitsnum_contractsparallel 1-photon V2 contract UTXOs (contractRef[i] = commit:(i+1),tokenRef = commit:0) + thegly/CBOR reveal scriptSig suffix + optional premine FT output + optional OP_RETURN. The returnedDmintV1RevealScriptsbag has the same shape — and the same output-ordering rule — for V1 and V2.- Parameters:
commit_txid (str)
- Return type:
- commit_result: CommitResult¶
- class pyrxd.glyph.FoldedRecord[source]¶
Bases:
objectA mutable glyph’s
attrsas of some point in its chain.- __init__(attrs, steps_applied, through_txid, incomplete, reason='')¶
- class pyrxd.glyph.FtAirdropBuild[source]¶
Bases:
objectA signed, un-broadcast FT airdrop — the multi-recipient form of
FtTransferBuild.- Parameters:
tx – the signed
Transactionfee – photons paid, sourced from plain RXD rather than from the token
ref – the token distributed
recipients – destinations in output order, so
recipients[i]describes vouti. Callers reconcile a broadcast against this, and an unordered collection would make that reconciliation guesswork.
- __init__(tx, fee, ref, recipients)¶
- Parameters:
tx (Transaction)
fee (int)
ref (GlyphRef)
recipients (tuple[AirdropRecipient, ...])
- Return type:
None
- tx: Transaction¶
- recipients: tuple[AirdropRecipient, ...]¶
- class pyrxd.glyph.FtAirdropParams[source]¶
Bases:
objectParameters for a multi-recipient FT airdrop.
Mirrors
FtTransferParams, withamount+new_owner_pkhreplaced by an ordered list ofAirdropRecipient.- Parameters:
ref – the
GlyphRefidentifying the tokenutxos – list of
FtUtxoavailable to spendrecipients – ordered destinations. Output order follows this list.
private_key – sender’s
pyrxd.keys.PrivateKeyfunding – plain-RXD
AirdropFundinginputs that pay the fee. The token cannot pay it — an FT output’s value is its unit count.fee_rate – photons/byte. Validated against Radiant’s effective relay floor by the builder.
change_pkh – FT- and RXD-change PKH. Defaults to the sender’s.
dust_limit – photons on each recipient output. A pyrxd wallet-policy floor, not a chain rule — Radiant’s dust threshold is 1.
royalty – optional. Advisory — see
pyrxd.glyph.royalty.sale_price – photons the seller receives; the royalty base, and the cap — a royalty can never exceed it.
pay_royalty –
None(default) pays iffroyalty.enforced;Truepays an advisory royalty anyway;Falsenever pays.allow_overpay – accept a
fee_rateabove the overpay ceiling, forwarded tobuild_airdrop_tx(). Same omission, and the same reason it matters, asFtTransferParams— see its note.
- __init__(ref, utxos, recipients, private_key, funding=<factory>, fee_rate=10000, change_pkh=None, dust_limit=546, royalty=None, sale_price=0, pay_royalty=None, allow_overpay=False, allow_below_relay_floor=False)¶
- Parameters:
ref (GlyphRef)
recipients (list[AirdropRecipient])
private_key (Any)
funding (list[AirdropFunding])
fee_rate (int)
change_pkh (Hex20 | None)
dust_limit (int)
royalty (GlyphRoyalty | None)
sale_price (int)
pay_royalty (bool | None)
allow_overpay (bool)
allow_below_relay_floor (bool)
- Return type:
None
- royalty: GlyphRoyalty | None = None¶
- recipients: list[AirdropRecipient]¶
- funding: list[AirdropFunding]¶
- class pyrxd.glyph.FtAirdropResult[source]¶
Bases:
objectOutput of
FtUtxoSet.build_airdrop_tx().- Parameters:
tx – signed
Transaction, ready to broadcastrecipient_scripts – FT locking scripts, index-aligned with the
recipientsargument and withtx.outputs— the builder never reorders an airdrop list, so a caller can reconcile who got what by index.change_ft_script – locking script of the FT change output, or
Nonewhen the airdrop consumed the selected inputs exactly.rxd_change_photons – photons returned as a plain P2PKH change output, or
0when there was none (see the builder’s docstring for where leftover RXD goes).royalty_payouts – royalty recipients actually paid, in output order.
ref – the FT’s
GlyphReffee – fee paid in photons. This is the actual fee —
value_in - value_out— which can exceedsize * fee_ratewhen a sub-dust remainder was folded into it rather than emitted as change.
- __init__(tx, recipient_scripts, change_ft_script, rxd_change_photons, royalty_payouts, ref, fee, recipients=<factory>)¶
- royalty_payouts: tuple[RoyaltyPayout, ...]¶
- recipients: tuple[AirdropRecipient, ...]¶
- class pyrxd.glyph.FtTransferBuild[source]¶
Bases:
objectA signed, un-broadcast FT transfer.
- Parameters:
tx – the signed
Transactionfee – photons paid, sourced from plain RXD rather than from the token
ref – the token transferred
amount – units delivered to
to_pkh— sized from this number, not from the inputs’ valueto_pkh – recipient’s 20-byte public-key hash
- __init__(tx, fee, ref, amount, to_pkh)¶
- Parameters:
tx (Transaction)
fee (int)
ref (GlyphRef)
amount (int)
to_pkh (Hex20)
- Return type:
None
- serialize()[source]¶
Raw transaction BYTES, ready for
await client.broadcast(...).Annotated
-> strand documented as “hex” until 2026-08-15, which was wrong on both counts:Transaction.serialize()returns bytes andElectrumXClient.broadcast()takes them. Runtime was always correct; the contract was not, and it was the same mistaken belief that madeassert_fee_matches_size()halve every size it judged. CI’s mypy scope issrc/pyrxd/security/only, so nothing checked this annotation.- Return type:
- tx: Transaction¶
- class pyrxd.glyph.FtTransferParams[source]¶
Bases:
objectParameters for an FT transfer transaction.
- Parameters:
ref – the
GlyphRefidentifying the tokenutxos – list of
FtUtxoavailable to spendamount – FT units to send to
new_owner_pkhnew_owner_pkh – recipient’s 20-byte PKH
private_key – sender’s
pyrxd.keys.PrivateKeyfunding – plain-RXD
AirdropFundinginputs that pay the fee. Required in practice: an FT output’s value is its unit count, so taking the fee from the token would burn units and short the recipient. A transfer with no funding raises.fee_rate – photons/byte (Radiant post-V2 minimum is 10_000)
change_pkh – FT- and RXD-change recipient PKH. Defaults to the sender’s PKH when
None.dust_limit – fold-to-fee threshold for the plain-RXD change output. Not a floor on the token output.
allow_overpay – accept a
fee_rateabove the overpay ceiling (MAX_FEE_OVERPAY_MULTIPLEx the relay floor), forwarded tobuild_transfer_tx(). This dataclass had no such field, so the ceiling was unreachable through this API:fee_rate=100_001raised with no way through, while the identical build viaFtUtxoSet.build_transfer_tx(..., allow_overpay=True)succeeded. A bound with no reachable override is a guard that refuses valid work, and Radiant has neither RBF nor CPFP to repair a late refusal.
Note
No
royaltyhere, unlikeFtAirdropParams.FtTransferResulthas nowhere to report who was paid, and paying a royalty without reporting it would be worse than not offering the option. UseFtAirdropParamswith one recipient.- __init__(ref, utxos, amount, new_owner_pkh, private_key, funding=<factory>, fee_rate=10000, change_pkh=None, dust_limit=546, allow_overpay=False, allow_below_relay_floor=False)¶
- funding: list[AirdropFunding]¶
- class pyrxd.glyph.FtTransferResult[source]¶
Bases:
objectOutput of
FtUtxoSet.build_transfer_tx().- Parameters:
tx – signed
Transaction, ready to broadcastnew_ft_script – locking script of the transfer (recipient) output
change_ft_script – locking script of the change output, or
Noneif the transfer was an exact matchref – the FT’s
GlyphReffee – fee paid in photons
Note
No
royalty_payoutshere, unlikeFtAirdropResult. Paying a royalty without reporting who was paid would be worse than not offering it, soFtUtxoSet.build_transfer_tx()takes noroyaltyargument at all. UseFtUtxoSet.build_airdrop_tx()(one recipient is a legal airdrop) when a royalty is in play — it returns the payouts.- __init__(tx, new_ft_script, change_ft_script, ref, fee)¶
- class pyrxd.glyph.FtUtxo[source]¶
Bases:
objectA single UTXO holding some quantity of one FT.
valueandft_amountare the SAME NUMBER, and this class refuses to hold them apart. Radiant’s FT conservation epilogue sums the satoshi values of the outputs carrying the token’s code-script hash (OP_CODESCRIPTHASHVALUESUM_UTXOS/_OUTPUTSpushsumAmount / SATOSHI—Radiant-Core/src/script/interpreter.cpp:2196and:2215), so an FT’s quantity is not merely conventionally its output value, it is its output value at the consensus layer. There is no second number to disagree with.Why that is enforced here, in
__post_init__, rather than in the builder that consumes it: anFtUtxowithvalue != ft_amountdescribes a UTXO that has never existed on Radiant and never can, and this repo has already shipped the consequence twice. The transfer builder used to size the recipient output from the inputs’ RXD instead of the requested amount and delivered 46,739,454 units for anamount=250request; the first “fix” was anif value == ft_amount: raiseguard inside the builder, which left the fund loss reachable atvalue == ft_amount ± 1. A guard inside one caller only protects that caller. Refusing at construction means noFtUtxoanywhere in the process can carry the bad state, so no builder — including one not yet written — can be handed it.It also fixes the two set-level queries that had no guard at all:
FtUtxoSet.total()sumsft_amountandFtUtxoSet.select()ranks and covers by it, so a wrongft_amountused to report a wrong balance and pick the wrong inputs long before any builder’s backstop ran.Build one with
from_output()when reading a UTXO off chain — it takes the output value once and there is no second field to get wrong.- Parameters:
txid – txid of the UTXO
vout – output index within that tx
value – photons on the output — which IS the token quantity
ft_amount – token units held on the output. Must equal
value; kept as an explicit field only so existing callers and theu.ft_amountreading sites keep working.ft_script – full FT locking script (75 bytes, see
pyrxd.glyph.script.build_ft_locking_script())
- Raises:
ValidationError –
valueorft_amountis not a non-negativeint, or the two differ.
- __init__(txid, vout, value, ft_amount, ft_script)¶
- class pyrxd.glyph.FtUtxoSet[source]¶
Bases:
objectManages a set of FT UTXOs for a single token
ref.Responsibilities:
Total the FT amount across the set.
Select a minimum set of UTXOs to cover a requested transfer amount.
Build + sign a transfer tx (two-pass fee calculation) that respects conservation.
- build_airdrop_tx(recipients, private_key, funding=(), fee_rate=10000, change_pkh=None, dust_limit=546, *, royalty=None, sale_price=0, pay_royalty=None, allow_overpay=False, allow_below_relay_floor=False)[source]¶
Build one signed transaction paying FT units to N recipients.
Why one transaction rather than N calls to
build_transfer_tx(): 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. One transaction lands whole or not at all.Conservation goes through the same path, not around it. The per-ref input check is
FtUtxoSet.__init__(), which refuses any UTXO whose embedded ref differs from the set’s; “do I hold enough” isselect(); and the arithmetic is the sameft_in - out == changeidentity as a single transfer, withoutnowsum(r.amount). No new code computes token amounts, so there is no new way to mint units.Each recipient output carries exactly the units requested. On Radiant an FT’s quantity is its output’s
satoshis— 1 photon = 1 unit (docs/concepts/radiant-fts-are-on-chain.md) — so an output’s value is not free to choose. Setting it to anything butamountwould deliver a different number of tokens than the caller asked for. That is also why the fee cannot come out of the token: subtracting it from an output would silently burn units. It comes from plain-RXDfundinginputs instead, the same waytransfer-nftsources a separate input to pay for a dust-carrying singleton.Output layout, in this exact order:
[0 .. N-1] recipient FT outputs, value == units, order preserved [N] FT change, value == leftover units (iff any remain) [...] royalty payouts, plain P2PKH (iff a royalty is paid) [last] plain P2PKH RXD change (iff >= dust_limit)
Floors. A recipient output’s floor is 1 photon, the chain’s actual rule —
GetDustThresholdreturns 1 satoshi unconditionally (Radiant-Core/src/policy/policy.cpp:19-25). It is deliberately NOT 546: an FT output’s value is a token quantity, so a 546 floor would forbid airdropping 100 units of anything.dust_limithere governs only the plain-RXD change output — a remainder below it is folded into the fee instead of being emitted, matchingpyrxd.swap.partial._balance_and_add_change(). Folding can only raise the fee paid, never lower it, so it cannot produce an under-fee’d transaction;FtAirdropResult.feereports the real amount.- Parameters:
recipients (Sequence[AirdropRecipient]) – destinations, in output order. Non-empty, at most
MAX_AIRDROP_RECIPIENTS, no repeated PKH.private_key (Any) –
pyrxd.keys.PrivateKeyowning every selected FT input (single-key, asbuild_transfer_tx()).funding (Sequence[AirdropFunding]) – plain-P2PKH RXD UTXOs paying the fee and any royalty. Each carries its own key, so the RXD may sit at a different wallet address from the token.
fee_rate (int) – photons/byte. Validated against Radiant’s effective relay floor — see
_check_fee_rate().change_pkh (Hex20 | None) – FT- and RXD-change PKH. Defaults to the sender’s, derived from
private_key.dust_limit (int) – fold-to-fee threshold for the RXD change output.
royalty (GlyphRoyalty | None) – optional. Advisory — see
pyrxd.glyph.royalty.sale_price (int) – photons the seller receives; the royalty base. Also the cap: a royalty can never exceed it.
pay_royalty (bool | None) –
None(default) pays iffroyalty.enforced;Truepays an advisory royalty anyway;Falsenever pays. See_resolve_royalty().allow_overpay (bool)
allow_below_relay_floor (bool)
- Raises:
ValidationError – empty/oversized recipient list, a duplicate recipient PKH, a non-positive amount, a malformed PKH, or the conservation backstop.
ValueError – fee rate below the relay floor, or the funding cannot cover
fee + royalty.
- Returns:
- Return type:
- build_transfer_tx(amount, new_owner_pkh, private_key, fee_rate=10000, change_pkh=None, dust_limit=546, funding=(), *, allow_overpay=False, allow_below_relay_floor=False)[source]¶
Build a signed FT transfer:
amountunits of this token to one PKH.A single-recipient
build_airdrop_tx(), and deliberately nothing more. The recipient output’s value isamountand the change output’s value isft_in - amount, because on Radiant an FT’s quantity is its output’ssatoshis— 1 photon = 1 unit (docs/concepts/radiant-fts-are-on-chain.md).Warning
Fund-safety history — read before “simplifying” this back. This method used to size the recipient output from the inputs’ RXD (
rxd_in_total - fee - change_alloc) rather than fromamount. On a realistic holding — one 50,000,000-unit UTXO,amount=250— that delivered 46,739,454 units to the recipient and kept 546: the sender’s whole balance, silently, to a counterparty who asked for 250. An interim patch added anif value == ft_amount: raisetripwire; re-running atvalue == ft_amount ± 1still delivered ~46.7 million units, because the sizing expression was never touched. The only fix that holds for input shapes nobody predicted is to size the output from the number the caller asked for, which is what the airdrop builder does — so this now is the airdrop builder.The token cannot pay its own fee. Every photon on an FT input is a token unit, so subtracting a fee from a token output burns units. The fee comes from plain-RXD
fundinginputs, exactly astransfer-nftsources a separate input to move a dust-carrying singleton. A call with nofundingtherefore raises rather than quietly shipping a 0-fee transaction that no node will relay.Output layout:
[0] recipient FT output, value == amount [1] FT change, value == ft_in - amount (iff any) [last] plain P2PKH RXD change (iff >= dust_limit)
- Parameters:
amount (int) – FT units to transfer to
new_owner_pkhnew_owner_pkh (Hex20) – recipient’s 20-byte PKH
private_key (Any) –
pyrxd.keys.PrivateKeyowning the inputsfee_rate (int) – photons/byte. Validated against Radiant’s effective relay floor — see
_check_fee_rate().change_pkh (Hex20 | None) – FT- and RXD-change PKH. Defaults to the sender’s PKH derived from
private_key.dust_limit (int) – fold-to-fee threshold for the RXD change output. NOT a floor on the token output: Radiant’s dust threshold is 1 photon, and a 546 floor would forbid transferring 100 units of anything.
funding (Sequence[AirdropFunding]) – plain-P2PKH RXD UTXOs paying the fee.
allow_overpay (bool) – accept a
fee_rateabove the overpay ceiling. The deliberate, greppable opt-out, mirroringallow_below_relay_floorat the other end — a ceiling with no reachable override refuses valid work, and on a chain with neither RBF nor CPFP a refusal can cost the funds it was protecting.allow_below_relay_floor (bool)
- Raises:
ValidationError –
new_owner_pkhis not 20 bytes, or a selected UTXO hasvalue != ft_amount(the fail-closed backstop — seebuild_airdrop_tx()).ValueError –
amount <= 0; total FT <amount;fee_ratebelow the relay floor; orfundingcannot cover the fee.
- Returns:
FtTransferResult(signed tx, scripts, fee, ref).- Return type:
- select(amount)[source]¶
Greedily select the minimum number of UTXOs covering
amount.Strategy: sort by
ft_amountdescending, take until covered.- Raises:
ValueError –
amountexceedstotal()(including the empty-set case, wheretotal == 0).- Parameters:
amount (int)
- Return type:
- class pyrxd.glyph.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]Mint a plain FT
[FT]Mint a dMint FT
[FT, DMINT]prepare_dmint_deploy()(3 txs)Mint a mutable NFT
[NFT, MUT]Mint a collection
``[NFT,CONTAINER]`
Mint into a collection
[NFT]+inMint a WAVE name
[NFT,MUT,WAVE]For every token type the first step is the same: call
prepare_commit()(which derives the commit script from the metadata protocol list automatically). Only the reveal step differs.Transfers (no commit needed)
NFT transfer:
build_nft_transfer_tx()FT transfer:
build_ft_transfer_tx()(orFtUtxoSetinglyph/ft.py)
Low-level (rarely called directly)
prepare_reveal()— generic reveal;is_nftpicks singleton vs FT reftypebuild_reveal_scripts()— alternate reveal entry that returns scripts, not paramsbuild_transfer_locking_script()— bare FT lock without constructing a txbuild_contract_script()— MUT contract script for mutable NFT reveals
- build_ft_airdrop_tx(params)[source]¶
Build one signed transaction paying FT units to many recipients.
Thin delegator to
FtUtxoSet.build_airdrop_tx(), exactly asbuild_ft_transfer_tx()delegates tobuild_transfer_tx— the selection, conservation and two-pass fee logic live on the UTXO set so both API surfaces share one implementation rather than two that can disagree about how many units exist.- Parameters:
params (FtAirdropParams) –
FtAirdropParams— see dataclass docstring.- Returns:
- Raises:
ValidationError – bad recipient list, or the conservation backstop.
ValueError – fee rate below Radiant’s relay floor, or the selected inputs’ RXD cannot cover dust + royalty + fee.
- Return type:
- build_ft_transfer_tx(params)[source]¶
Build a signed FT transfer transaction enforcing conservation.
Thin delegator to
FtUtxoSet.build_transfer_tx()— the real logic (selection, two-pass fee, conservation) lives there so the API surface is available both at the builder level and directly on a UTXO-set instance. That method is itself a single-recipientFtUtxoSet.build_airdrop_tx(), so the recipient output’s value isparams.amountand nothing else.- Parameters:
params (FtTransferParams) –
FtTransferParams— see dataclass docstring.- Returns:
FtTransferResult— signed tx + scripts + fee.- Raises:
ValueError – same conditions as
FtUtxoSet.build_transfer_tx()(insufficient FT balance, sub-floor fee rate, funding too small).- Return type:
- build_nft_transfer_tx(params)[source]¶
Build a signed NFT transfer transaction.
Spends an existing NFT UTXO (standard P2PKH scriptSig unlock: <sig> <pubkey>) and creates a new NFT output locked to
new_owner_pkh. The 36-byte ref is preserved across the transfer — it’s extracted from the input’s NFT script and written into the new output’s NFT script unchanged.Fee calculation is two-pass: build a trial tx, sign it to measure actual serialised size, then rebuild with the final value = input_value - size*fee_rate. The trial signature is discarded (reset unlocking_script = None before final sign) so the final tx carries a signature over the final outputs, not the trial ones.
A CONTAINER is transferred by this method too — its locking script is the 63-byte NFT singleton, and its collection membership lives in the envelope, so a transfer cannot drop it. Nothing extra to do.
- Parameters:
params (TransferParams) – TransferParams — see dataclass docstring
- Returns:
TransferResult — signed tx, new locking script, ref, fee
- Raises:
ValidationError – nft_script is not a valid 63-byte NFT script
ValueError – nft_utxo_value - fee below pyrxd’s uneconomic-output floor
pyrxd.constants.DUST_THRESHOLD_PHOTONS— a pyrxd policy, not a Radiant relay limit
- Return type:
TransferResult
- build_transfer_locking_script(ref, new_owner_pkh, is_nft)[source]¶
Build the locking script for a transfer output.
- prepare_authority_gated_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, authority_ref, authority_script)[source]¶
Prepare scripts for minting an item gated on an issuer’s authority.
Build the reveal with two inputs — the commit outpoint and the authority token’s UTXO — and two token outputs:
output
script
0item_script1authority_script(plus change). The authority output is re-emitted VERBATIM, so it neither moves nor changes hands — and, more to the point, is not destroyed. Spending a singleton without re-creating it burns it irrecoverably.
authority_script is the authority UTXO’s CURRENT locking script, not a PKH, for two reasons. Rebuilding it from a PKH with
build_nft_locking_scriptSTRIPS anything the authority itself carried — an authority that is itself authority-gated came back un-gated, silently, in the transaction that was supposed to leave it untouched. And aHex20here sat next to owner_pkh, so transposing the two irreversibly gifted the issuer’s authority to the mint recipient in a transaction consensus accepts. A script cannot be confused with a PKH.Consensus enforces the gate:
OP_REQUIREINPUTREFin the item’s script is subset-checked against this transaction’s inputs, so the mint fails outright without the authority. Measured, along with what the gate does NOT bind, intests/test_authority_regtest_e2e.py— readbuild_authority_gated_nft_script()before treating “gated” as a durable property of the minted item.
- static prepare_burn_proof(token_ref, *, amount=None, burn_reason=None)[source]¶
The
OP_RETURNoutput that records a deliberate burn.Add it to the transaction that spends the token, with value 0, and do NOT re-create the token in any output. See
build_burn_proof_script()for what the proof does and does not establish — it is an operator claim, andverify_burn()is careful about which parts of it a reader may rely on.
- prepare_commit(params)[source]¶
Prepare the commit transaction parameters.
Returns the commit locking script + CBOR bytes + estimated fee. Caller must build, sign, and broadcast the actual transaction.
The commit script’s
OP_REFTYPE_OUTPUTcheck is derived frommetadata.protocol: NFT (2in protocol) produces anOP_2/SINGLETON-expecting commit; any other protocol mix (FT, dMint FT, data, etc.) produces anOP_1/NORMAL-expecting commit. This means the caller does not hand-pick refType — the metadata drives it. Prior versions forced every commit to NFT shape; seebuild_commit_locking_scriptfor the fix note.- Parameters:
params (CommitParams)
- Return type:
CommitResult
- prepare_container_child_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, container_ref, *, container_script)[source]¶
Prepare scripts for revealing a token into a container.
The child is an ordinary NFT. What makes its membership checkable is the transaction shape: the reveal spends the container’s own NFT UTXO and re-creates it unchanged, so the container ref appears among the reveal’s output-script refs. Photonic’s indexer only honours an
inentry that it can find there (filterRels,packages/app/src/electrum/worker/NFT.ts) — a claimedinref with no matching output ref is dropped, which is what stops anyone declaring their token part of someone else’s collection.Build the reveal with two inputs — the commit outpoint and the container NFT UTXO — and two token outputs:
output
script
01(plus any change).
container_scriptis the container’s OWN current locking script and is re-emitted VERBATIM, so output1is byte-identical to the UTXO being spent by construction rather than by assumption. It previously took aHex20and rebuilt the container withbuild_nft_locking_script, which silently returned an authority-gated, mutable or soulbound container as a plain 63-byte NFT — same ref, covenant gone — in the one transaction whose purpose is to leave the container untouched. Re-gating needs the issuer, so the loss was not recoverable by the holder.To re-own the container deliberately, pass the script you want it to have; that keeps the intent visible at the call site instead of hiding it behind a PKH argument that sat next to
owner_pkhand could be transposed with it.cbor_bytesMUST already declare the membership — encode the child’s metadata withcontainer_refs=[container_ref]. This method cross-checks it rather than editing the payload, because the payload hash is already committed to on chain by the commit output.- Raises:
ValidationError – the envelope is unparseable, does not include
GlyphProtocol.NFT, or itsinlist does not containcontainer_ref.- Parameters:
- Return type:
- prepare_container_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, child_ref=None)[source]¶
Prepare scripts for a CONTAINER (collection) reveal.
A container’s locking script is the plain 63-byte NFT singleton of
build_nft_locking_script(). Container-ness is carried by the7marker in the envelope’spfield, exactly as in Photonic Wallet (packages/lib/src/script.tshas onenftScriptand no container variant). That is what makes a container a first-class token: every NFT classifier, the scanner, andbuild_nft_transfer_tx()handle it unchanged.Membership points child → parent and lives in the child’s envelope, in the
infield (container_refs). Useprepare_container_child_reveal()to mint a member.Protocol field must include
GlyphProtocol.CONTAINER(7).- Parameters:
- Return type:
The
child_refprefix (removed in 0.15.0)¶pyrxd 0.9.0–0.14.0 prefixed the NFT body with
OP_PUSHINPUTREF <child_ref>whenchild_refwas given. That 100-byte script was never a working token, and both defects were confirmed against a Radiant Core v3.1.1 regtest node (tests/test_container_regtest_e2e.py):The output could not be spent.
OP_PUSHINPUTREFleaves the ref on the stack and nothing drops it, so the P2PKH tail hashed the ref andOP_EQUALVERIFYfailed for every possible scriptSig. Any photons placed on it were unrecoverable.Creating one destroyed the child NFT.
OP_PUSHINPUTREFSINGLETONalso registers its ref as a disallowed sibling (CScript::GetPushRefs), so the child could not be re-created alongside the container — and a singleton consumed into a0xd0push never re-entersinputSingletonRefSet, so it can never be minted again.
Even with the missing
OP_DROPrepaired, a script-level link to a live NFT is impossible on Radiant for the second reason, and a repaired link would still be droppable by the holder at any transfer. Membership is therefore metadata, here as in Photonic.
- prepare_dat_commit(params)[source]¶
Prepare a DAT (data-storage) commit — a glyph that mints no token.
Same two-transaction shape as
prepare_commit(), but the commit carries noOP_REFTYPE_OUTPUTobligation, so the reveal creates no NFT and no FT. What survives is the payload in the reveal’s scriptSig.Protocol must include
GlyphProtocol.DAT(3). Build the reveal withprepare_dat_reveal()— a DAT commit pops an extra"dat"marker and the ordinary reveal scriptSig is one push short of satisfying it.- Parameters:
params (CommitParams)
- Return type:
CommitResult
- prepare_dat_reveal(cbor_bytes, *, delegate_ref=None)[source]¶
Prepare a DAT reveal’s scriptSig suffix.
There is no
locking_script: a DAT reveal mints nothing, so the caller’s outputs are whatever they want to keep the value on (ordinary P2PKH change).RevealScripts.locking_scriptis returned empty to say so rather than handing back a token script that would be wrong.
- prepare_delegate_setup(owner_pkh, authorised_refs, *, parent_scripts, base_ref=None, token_count=0)[source]¶
Prepare the one-time delegate setup that authorises
in/byclaims.This is the alternative to
prepare_container_child_reveal()for a minting service, and the only write path pyrxd has forbyat all. Where the container-child reveal makes every mint spend and re-create the parent — permanent custody in the minting wallet, and one serialised UTXO every mint contends on — a delegate spends the parents once.Two transactions, because the second needs the first’s outpoint:
Call with authorised_refs only. Spend the container and/or author tokens, with outputs =
base_scriptplus every script in :attr:`~DelegateSetupScripts.parent_scripts`, which re-create the parents unchanged. Consensus refuses the base output unless those refs really were among the inputs (OP_REQUIREINPUTREFis subset-checked), which is what makes every later claim authorised rather than merely asserted.Omitting the parent outputs BURNS the container and author tokens.
OP_REQUIREINPUTREFrequires a ref as an input and does not carry it forward, so a base transaction that does not re-create the parents destroys them — permanently, since a consumed singleton can never be re-minted. Only once the parents are back in outputs is it true that they can go to cold storage and never be spent again.Call again with base_ref (that output’s outpoint) and a token_count. Spend the base, paying to each of
token_scripts. Pre-mint as many as you expect to need — N tokens serve N concurrent mints with no lock on a shared UTXO, which is the operational point.
Warning
A delegate token is not one mint. It is an unlimited mint pass. An earlier version of this text said “each token authorises one mint”; that was wrong, and MEASURED wrong on a node (
test_ONE_delegate_token_can_mint_MANY_more). The 56-byte prefix is a covenant on the REVEAL. The commit that spends a delegate token is an ordinary transaction under no covenant, and the token’s script isOP_PUSHINPUTREF <base>— so spending one puts the base ref in the input ref set, and consensus lets one input ref back arbitrarily many output copies. One token was spent into three on regtest.So a token handed to a third party lets them mint into the collection without limit until the base is retired. Treat the tokens as bearer credentials for the collection, not as counted vouchers, and keep them in the minting service rather than distributing them.
Then each mint passes
base_refasCommitParams.delegate_ref, spends one token in the commit, and emitsRevealScripts.delegate_burn_scriptin the reveal.What this does NOT prove: that the parent’s owner approved this particular mint. It proves the mint held a token from a base that held the parents. Anyone holding a delegate token can make the claim — that is the mechanism working as designed, and why
RelationshipBasisreports DELEGATED separately from DIRECT rather than flattening the two.- Parameters:
parent_scripts (Sequence[bytes]) –
the parents’ OWN current locking scripts, in the same order as authorised_refs, re-created verbatim.
This used to be a single
parent_owner_pkhand the outputs were rebuilt withbuild_nft_locking_script(). That is the exact hazardprepare_authority_gated_reveal()documents forty lines from here and refuses: rebuilding from a PKH STRIPS whatever the parent itself carried. A container or author that is itself authority-gated (101 bytes), mutable, or held by a soulbound covenant came back as a plain 63-byte NFT — ref preserved, covenant gone — in the one transaction whose stated purpose is to leave the parents untouched before they return to cold storage.Taking the scripts verbatim also settles the question the old parameter existed for. A parent keeps paying whoever it already paid, so a hot minting service cannot silently move a cold-held singleton to the hot key, and two parents held by two different keys are not consolidated. Both were argued for at length in prose; now neither is expressible.
Each script is cross-checked with
script_carries_ref()against the ref it is paired with, so a mismatched or reordered list is refused rather than silently re-creating the wrong parent.owner_pkh (Hex20)
base_ref (GlyphRef | None)
token_count (int)
- Raises:
ValidationError – authorised_refs is empty, or token_count is given without base_ref (or vice versa with no tokens to build).
- Return type:
DelegateSetupScripts
- prepare_dmint_deploy(params, *, allow_v2_deploy=True)[source]¶
Prepare a dMint token deploy.
Dispatches on the type of
params:DmintV1DeployParams→ returnsDmintV1DeployResult. V1 is the only format on Radiant mainnet today (see GLYPH at a443d9df…878b). Two-tx deploy: commit + reveal (the reveal directly createsparams.num_contractsparallel contract UTXOs).DmintV2DeployParams→ returnsDmintV2DeployResult. V2 is consensus-proven on regtest + mainnet (#219) and now deploys by default (allow_v2_deploy=True). A softUserWarningis emitted if the caller explicitly passesallow_v2_deploy=Falseso the historical opt-out path stays observable without blocking.
- Parameters:
params (DmintV1DeployParams | DmintV2DeployParams) – Either
DmintV1DeployParams(V1 deploy) orDmintV2DeployParams(V2 deploy). The deprecatedDmintFullDeployParamsis accepted (it’s a subclass ofDmintV2DeployParams) but emits aDeprecationWarningat construction time.allow_v2_deploy (bool) – Retained for backward-compatibility; defaults to
True(V2 deploys by default). Ignored for V1.
- Returns:
V1 or V2 result, matching the param type via
@overload.- Raises:
ValidationError – Various per-version invariants — see
_prepare_dmint_v1_deploy()and the V2 implementation below for specifics.- Return type:
- prepare_ft_deploy_reveal(commit_txid, commit_vout, commit_value, cbor_bytes, premine_pkh, premine_amount)[source]¶
Prepare reveal scripts + premine amount for an FT deploy.
Thin convenience wrapper around
prepare_reveal()for the FT-deploy-with-premine flow: the reveal produces one FT output carrying the full issued supply topremine_pkh. The permanent token ref is the commit outpoint (commit_txid:commit_vout), which this method embeds into the reveal’s locking script — not the reveal’s own outpoint.Caller still constructs the actual transaction. The returned
premine_amountis whatvout[0].valuemust be on the reveal tx — typically the full supply for a premine-only deploy (no covenant UTXO). Radiant FT convention: 1 photon = 1 FT unit, sopremine_amountis the supply in whole units.No dMint-specific logic here. The
cbor_bytesalready encode whatever protocol markers the caller chose — dMint FT ([1,4]), plain FT ([1]), or any other combination — viaGlyphMetadata. pyrxd treats the protocol markers as caller-owned; classification happens at the indexer layer.
- prepare_mutable_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh)[source]¶
Prepare scripts for a MUT (mutable NFT) reveal.
Returns the two output locking scripts the caller must place in the reveal tx:
nft_script: 63-byte NFT singleton (token the owner holds), carryingref = commit_txid:commit_voutcontract_script: 174-byte mutable contract UTXO (holds state), carryingmutable_ref = commit_txid:(commit_vout + 1)
The reveal scriptSig suffix is also returned; the caller prepends
<sig> <pubkey>to form the full scriptSig.Protocol field in
cbor_bytesmust includeGlyphProtocol.MUT(5). UseGlyphMetadata(protocol=[GlyphProtocol.NFT, GlyphProtocol.MUT]).The reveal needs TWO inputs¶
input
outpoint
0commit_txid:commit_vout— the commit (reveal scriptSig)1commit_txid:(commit_vout + 1)— a plain seed outputThe commit transaction must therefore carry a second, ordinary output at ``commit_vout + 1`` (Photonic funds it with 1 photon; Radiant has no dust rule). Spending it is what puts
mutable_refinto the transaction’s input singleton-ref set, which is the only thing that lets an output push it.Why the two refs cannot be the same one¶
pyrxd 0.9.0-0.15.0 used
reffor both scripts. A reveal built as documented above was rejected by consensus every time — confirmed against a Radiant Core v3.1.1 regtest node (tests/test_mut_wave_regtest_e2e.py), reject reasonbad-txns-inputs-outputs-invalid-transaction-reference-operations. Two independent chain rules forbid it:OP_PUSHINPUTREFSINGLETONfiles its ref intofoundDisallowedSiblingRefsas well as the push-ref set (CScript::GetPushRefs), andvalidateTransactionReferenceOperationsrejects a transaction where two outputs claim the same one. Both the NFT script and the mutable contract lead with0xd8, so they can never carry the same ref.The contract’s own body derives the token ref from its ref by subtracting one from the vout (
OP_DUP 20 OP_SPLIT OP_BIN2NUM OP_1SUB OP_4 OP_NUM2BIN OP_CAT).mutable_ref.vout == ref.vout + 1is therefore not a convention — the covenant computes it. With equal refs the contract would look forcommit_vout - 1and match nothing, so even a repaired sibling rule would leave the contract unspendable.
This matches Photonic Wallet (
packages/lib/src/mint.ts:Outpoint.fromUTXO(mint.utxo.txid, mint.utxo.vout + 1)).Note
Spending the contract later (the
mod/sloperations ofbuild_mutable_scriptsig()) additionally requires the token output to be re-created in Photonic’snftAuthScriptshape — anOP_REQUIREINPUTREF <mutable_ref> <sha256(contract scriptSig)> OP_2DROPstate prefix ahead of the singleton. pyrxd has no builder for that shape yet; the working transaction is spelled out intests/test_mut_wave_regtest_e2e.py.- Parameters:
- Return type:
- prepare_reveal(params)[source]¶
Prepare the reveal transaction scripts.
Returns locking script + scriptSig suffix. Caller must build, sign, and broadcast the actual transaction.
- Parameters:
params (RevealParams)
- Return type:
RevealScripts
- prepare_wave_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, name, allow_confusable=False)[source]¶
Prepare scripts for a WAVE (on-chain naming) reveal.
WAVE extends MUT with a
namefield in the CBOR payload. Protocol field must includeGlyphProtocol.WAVE(11).namemust be non-empty, printable, at most 255 characters, and must not impersonate Latin text — seepyrxd.glyph.wave.validate_wave_text(), which is the single definition of that rule and is applied here and inbuild_wave_metadata(). This method is the funnel every WAVE registration crosses, whatever built its CBOR, so the check belongs here rather than only in the metadata helper a caller may not have used. Passallow_confusable=Trueto register a look-alike deliberately. The name is validated here but must already be embedded incbor_bytesby the caller via eitherattrs["name"](the Photonic-compatible canonical shape — required for resolution against RXinDexer and other indexers) or top-levelname(legacy pyrxd shape, accepted for backwards compatibility but not indexer-visible).Photonic-compatible CBOR shape (canonical, see Photonic Wallet
packages/lib/src/wave.ts):{ "p": [2, 5, 11], "attrs": { "name": "alice.rxd", "domain": "rxd", "target": "<radiant_address>", "target_type": "address" } }
Use
build_wave_attrs()(orpyrxd.glyph.wave.build_wave_metadata()) to construct the canonical shape; passing a top-levelnamefield still works but emits a token RXinDexer will not index.Protocol requirement:
[NFT(2), MUT(5), WAVE(11)].The reveal shape is MUT’s, including its two-input requirement: the commit outpoint plus a seed outpoint at
commit_vout + 1that gives the mutable contract its own singleton ref. Seeprepare_mutable_reveal()— a WAVE registration built without the seed input is rejected by consensus, as every one built through 0.15.0 was.
- class pyrxd.glyph.GlyphClient[source]¶
Bases:
objectMint and transfer Glyph tokens over one ElectrumX client and one wallet.
Usage:
client = GlyphClient(electrumx, wallet, store=JsonFilePendingStore("~/.pyrxd/pending")) result = await client.mint_nft(metadata) receipt = await client.transfer_ft(ref, 250, recipient_pkh)
Transfer-only callers can skip the store:
client = GlyphClient(electrumx, wallet) receipt = await client.transfer_ft(ref, 250, recipient_pkh)
- Parameters:
client – an ElectrumX-style client —
await broadcast(raw_tx: bytes) -> txid,await get_transaction(txid),await get_utxos(script_hash).wallet – an
HdWallet, or anything exposingawait collect_spendable(client),privkey_for_address(address)andaddresses.store – where a
PendingMintlives between commit and reveal. Required for minting, unused by transfers.fee_rate – photons per byte, applied to every build. A rate above the overpay ceiling is always refused here. A sub-floor rate is refused here too when
storeis given — mints judge it in the constructor — and otherwise left to each build path, since the floor is a property of the chain.allow_below_relay_floor – accept a sub-floor
fee_rate, for chains whose floor really is lower —relay_floor_photons_per_byte()is a fixed mainnet constant, and a regtest node runs at a tenth of it. Required at construction whenstoreis given, since minting judges the rate in the constructor rather than per build. Since #458 this covers transfers as well as mints, on both the FT and NFT paths: it is threaded to the FT builder and tobuild_nft_transfertogether, because doing it for one alone would reintroduce the FT/NFT asymmetry that caused a release blocker on this surface.min_confirmations – depth required on a mint’s commit before its reveal.
confirmation_timeout_s – how long a reveal waits for the commit.
poll_interval_s – seconds between confirmation polls, forwarded to
GlyphMinter. Lower it for a chain that mines on demand; the default suits minutes-apart blocks.
- __init__(client, wallet, *, store=None, fee_rate=10000, allow_below_relay_floor=False, min_confirmations=1, confirmation_timeout_s=1800.0, poll_interval_s=10.0)[source]¶
- async airdrop_ft(ref, recipients, *, allow_overpay=False)[source]¶
Distribute
refto many recipients in one transaction, and broadcast.The orchestration behind this lived only in the CLI until now, so a library caller had to reimplement it or drive the builder directly.
Like its transfer siblings the returned txid is derived from the bytes that were signed, not from the node’s echo: a lying or buggy server could drop the transaction and echo a well-formed — even real and already-confirmed — txid, leaving a caller polling something unrelated to their tokens.
- Raises:
BroadcastEchoMismatch – the node echoed a txid other than the one the signed bytes hash to. Deliberately not a
ValidationError: those are raised before anything is sent, and a caller retrying on one would re-broadcast a distribution that may already have moved tokens.InsufficientFundsError – not enough of the token, or no plain-RXD UTXO to pay the fee. Raised before anything is signed or sent.
- Parameters:
ref (GlyphRef)
recipients (Sequence[AirdropRecipient])
allow_overpay (bool)
- Return type:
- async broadcast_timelock_reveal(build)[source]¶
Broadcast a reveal that was already built and shown to someone.
Split out from
reveal_timelock()so a caller that displayed a build can send those bytes. The CLI confirms a reveal by printing the key it is about to publish; callingreveal_timelockafter that prompt would build a second transaction and broadcast it instead — a confirmation showing one artifact and sending another, which is worse than no confirmation because it looks like one.The build already carries a checked plan; there is no way to construct a
TimelockRevealBuildthat has not been throughplan_timelock_reveal().- Parameters:
build (TimelockRevealBuild)
- Return type:
TimelockRevealReceipt
- async build_ft_airdrop(ref, recipients, *, allow_overpay=False)[source]¶
Build and sign a multi-recipient FT airdrop without broadcasting it.
One transaction, not N transfers: sequential transfers chain, each spending the previous one’s change, so a failure partway leaves the set half-delivered and the token’s ref alone cannot tell you which half. Output order follows
recipients.- Parameters:
ref (GlyphRef)
recipients (Sequence[AirdropRecipient])
allow_overpay (bool)
- Return type:
- async build_ft_transfer(ref, amount, to_pkh, *, allow_overpay=False)[source]¶
Build and sign an FT transfer without broadcasting it.
For callers that want to show the user what is about to be spent — which is exactly what the CLI does — or to inspect the transaction first.
- Parameters:
- Return type:
- async build_nft_transfer(ref, to_pkh, *, allow_overpay=False)[source]¶
Build and sign an NFT transfer without broadcasting it.
The singleton keeps its own value; the fee comes from a separate plain-RXD input. See
pyrxd.glyph.transfer.build_nft_transfer()for why this does not go throughGlyphBuilder.build_nft_transfer_tx().- Parameters:
- Return type:
- build_timelock_mint(*, name, content_type, plaintext, params, cek=None, recipients=(), locator=None)[source]¶
Encrypt and seal content without minting it. See
build_timelock_mint().Synchronous and network-free: this is pure construction, and it is exposed on the client so a caller can inspect the envelope — and take a copy of the CEK — before committing anything to a chain.
mint_timelocked_nft()is this plus the mint.
- async build_timelock_reveal(metadata, *, token_ref, cek, hint='', allow_early=False, allow_overpay=False)[source]¶
Build and sign a reveal without broadcasting it — the dry run.
For showing an operator exactly what would become public before it does, which is what
pyrxd glyph timelock-reveal --dry-rundoes with it. The plan inside the returned build has already passed the commitment check and the unlock gate; there is no way to obtain one of these that has not.
- async commit_ft(metadata, *, supply, treasury_pkh=None)[source]¶
Phase 1 of an FT deploy. See
GlyphMinter.commit_ft().Added alongside
reveal_ft()rather than after it: exposing only the reveal would let a caller FINISH a two-phase FT deploy through this facade that they could not START through it — half of a pair is the asymmetry the facade exists to remove, not a smaller version of the fix.- Parameters:
metadata (GlyphMetadata)
supply (int)
- Return type:
- async commit_nft(metadata, *, owner_pkh=None)[source]¶
Phase 1 of an NFT mint. See
GlyphMinter.commit_nft().- Parameters:
metadata (GlyphMetadata)
- Return type:
- async deploy_ft(metadata, *, supply, treasury_pkh=None)[source]¶
Deploy a fungible token with a full premine. See
GlyphMinter.deploy_ft().Spelled out rather than
*args, **kwargs: this is a published SDK, and the erased signature was the only one of the five facade methods that gave a caller no completion, no type checking, and aTypeErrorfrom inside the minter instead of at the call site.- Parameters:
metadata (GlyphMetadata)
supply (int)
- Return type:
- async mint_nft(metadata, *, owner_pkh=None)[source]¶
Commit and reveal an NFT singleton. See
GlyphMinter.mint_nft().- Parameters:
metadata (GlyphMetadata)
- Return type:
- async mint_timelocked_nft(*, name, content_type, plaintext, params, persist=None, cek=None, recipients=(), locator=None, owner_pkh=None)[source]¶
Seal
plaintextbehind a timelock and mint the NFT that commits to its key.The mint itself is
mint_nft()— the same two-phase commit/reveal, the same store, the same fee rules. What this adds is the envelope: the content is encrypted withchunked-aead-v1, the key’s SHA-256 goes on chain ascrypto.timelock, and the key comes back to the caller.The CEK and ciphertext are not recoverable from the chain. The mint carries a commitment to the key and a hash of the plaintext, nothing more; a mint cannot be re-run and there is no path from
sha256(cek)back tocek.KEY CUSTODY HAS TO PRECEDE THE COMMIT, so this method makes you say how. Supply either
persist— called with theTimelockMintBuildafter the envelope is built and before a single byte is broadcast — orcek, a key you already hold. With neither, this raises before touching the network.The refusal is not pedantry about defaults. Generating the key inside a call that then blocks on confirmation put the only copy of it in a local variable for as long as a Radiant block takes: a
NetworkError, aConfirmationTimeoutError, a cancellation or a kill in that window and the receipt is never constructed, while the pending store holds a resumable commit whose CBOR commits tosha256(cek). The documented recovery —reveal_nft()on that pending mint — then succeeds, and mints a token nobody can ever open. The advice this docstring used to give instead (“persist both halves of the receipt before doing anything else with it”) is advice a caller cannot act on: the receipt does not exist until after the window has closed.pyrxd glyph timelock-mintnever had this problem because it writes its files before broadcasting; the hook is that ordering, for the SDK.persistmay be sync or async, and anything it raises propagates with nothing broadcast. The build it receives carriescek,ciphertext,cek_hash,stubandmetadata— andmetadatais worth saving too: a commit that confirms while its reveal does not is spendable only by a reveal pushing byte-identical CBOR, which a build withrecipientscannot reproduce.See
build_timelock_mint()for the rest of the arguments.owner_pkhbehaves as it does onmint_nft(), defaulting to the funding key’s own hash.- Raises:
ValidationError – neither
persistnorcekwas given, no store was configured (minting needs one), or the parameters were refused. Raised before anything is broadcast.- Parameters:
- Return type:
TimelockMintReceipt
- property minter: GlyphMinter¶
The underlying
GlyphMinter.Built on first use so a transfer-only client never needs a store.
- Raises:
ValidationError – if this client was constructed without a store.
- async plan_timelock_reveal(metadata, *, token_ref, cek, hint='', allow_early=False)[source]¶
Check a reveal against the chain’s own clock, and return what it would publish.
The clock is read here rather than taken from the caller, which is the point of the method existing:
plan_timelock_reveal()cannot judge a lock it is not given a time for, and a caller passing its own number is a caller who can pass the wrong one.mode="block"— the tip height fromget_tip_height().mode="time"— the timestamp in the tip block’s header, not this process’s wall clock. A local clock can be wrong by any amount and nothing would say so, and the header timestamp is at least the unit the lock was written in. It is not exact — a block’s timestamp may run ahead of real time under consensus rules — so this is a gate against the obvious mistake, not a substitute for the operator knowing what they are publishing.
The clock is the SERVER’S, and this SDK does not authenticate it. Neither read is verified:
get_tip_heightchecks only that a non-negative integer came back andget_block_headeronly that 80 bytes did. Nothing checks the proof of work behind that height, links the header to one already known, or asks a second endpoint — and pyrxd’s default endpoints are third-party public servers. So an endpoint that overstates the tip obtains a permanent early reveal from a gate that reports itself satisfied, and one that merely lags refuses an honest holder pastunlock_at. Calling this “the chain’s clock” would be the more reassuring sentence and it would not be true: it is one server’s claim about the chain.What the reveal path does with that is show it. The returned plan carries
judged_at— the reading actually compared against — andpyrxd glyph timelock-revealprints it besideopens atin the pre-broadcast summary, so the operator can disagree with a number that would otherwise never have been on screen. “Summary”, not “prompt”: under--yesthere is no question, and for a while that meant no summary either, which made this whole sentence true only of the interactive run. It is now printed on every path that can broadcast — stdout in human mode, stderr under--json/--quiet— and recorded again on the receipt afterwards. An SDK caller who needs more than that should pass a clock they trust toplan_timelock_reveal()directly.Everything the plan is checked for happens in the underlying function; see its docstring. This adds only the clock.
- async reveal_ft(pending, *, fee_rate=None, allow_below_relay_floor=None, allow_overpay=False)[source]¶
Phase 2 of an FT deploy. See
GlyphMinter.reveal_ft().The FT counterpart of
reveal_nft(), and it exists for the sharper half of the reason that one does. A two-phase FT deploy could not be FINISHED through this facade at all: a caller who committed had to reach past it into.minter. The commit output is a hashlock with no owner-only spend path, so the phase a caller most needs to resume was the one not exposed.allow_below_relay_floormust stayNone-defaulted, notFalse— the same trap documented at length onreveal_nft().Nonemeans “inherit the constructor”;Falsemeans “the caller re-asserted the floor for this reveal”. AFalsedefault here would forward a deliberate override on every ordinary call, so a client built withallow_below_relay_floor=Truewould commit and then refuse to reveal, stranding the commit and everything funded into it.- Parameters:
pending (PendingMint)
fee_rate (int | None)
allow_below_relay_floor (bool | None)
allow_overpay (bool)
- Return type:
- async reveal_nft(pending, *, fee_rate=None, allow_below_relay_floor=None, allow_overpay=False)[source]¶
Phase 2 of an NFT mint. See
GlyphMinter.reveal_nft().The three overrides are forwarded because without them the minter’s escape hatch is unreachable from this facade: a commit whose stored fee rate now sits below a risen relay floor cannot be revealed at all, and re-pricing upward is only possible when the commit holds enough to pay it.
allow_below_relay_floormust stayNone-defaulted, notFalse. The minter readsNoneas “the caller said nothing, inherit the constructor” andFalseas “the caller re-asserted the floor for this reveal”. Defaulting toFalsehere forwarded a deliberate override on every ordinary call, so a client built withallow_below_relay_floor=Truecommitted and then refused to reveal — stranding the commit, which is a hashlock with no owner-only spend path. That is exactly the failure the constructor flag exists to prevent, reintroduced one layer above it by a default that looked harmless.- Parameters:
pending (PendingMint)
fee_rate (int | None)
allow_below_relay_floor (bool | None)
allow_overpay (bool)
- Return type:
- async reveal_timelock(metadata, *, token_ref, cek, hint='', allow_early=False, allow_overpay=False)[source]¶
Publish the CEK on chain, and broadcast.
Irreversible. After this relays, anyone holding the ciphertext can decrypt it, forever. There is no unreveal and no second reveal.
Two mistakes are refused before anything is sent, both by
plan_timelock_reveal(): a CEK that is not the one this token committed to, and a reveal beforeunlock_atwithoutallow_early. Both areValidationErrorsubclasses, so they land with everything else raised pre-broadcast.- Raises:
CekCommitmentMismatch –
sha256(cek)is not the token’s commitment. Publishing it would spend the reveal and leave the payload unreadable for good.TimelockNotExpired – the lock has not expired and
allow_earlywas not set.BroadcastEchoMismatch – the node echoed a txid other than the one the signed bytes hash to. Deliberately not a
ValidationError— a caller retrying on one would re-publish a key that may already be public.InsufficientFundsError – no plain-RXD UTXO large enough to fund the reveal. Raised before anything is signed.
- Parameters:
- Return type:
TimelockRevealReceipt
- async transfer_ft(ref, amount, to_pkh, *, allow_overpay=False)[source]¶
Send
amountunits ofreftoto_pkh, and broadcast.The recipient output is sized from
amount; the fee is paid from a separate plain-RXD input, because an FT output’s value is its unit count and taking the fee from the token would short the recipient.- Raises:
InsufficientFundsError – not enough of the token, or no plain-RXD UTXO to pay the fee. Raised before anything is signed or sent.
ValidationError – inputs span multiple keys, the builder refused the parameters, or change would have been paid to the miner.
- Parameters:
- Return type:
- async transfer_nft(ref, to_pkh, *, allow_overpay=False)[source]¶
Send the NFT singleton
reftoto_pkh, and broadcast.The singleton’s value crosses unchanged and the fee is paid from plain RXD. There is no
amount: a singleton is indivisible, and the value on an NFT output is dust rather than a quantity.- Raises:
InsufficientFundsError – this wallet does not hold the NFT, or has no plain-RXD UTXO large enough to pay the fee. Raised before anything is signed or sent.
ValidationError – the signed transaction does not pay for its own size.
- Parameters:
- Return type:
NftTransferReceipt
- class pyrxd.glyph.GlyphCreator[source]¶
Bases:
objectCreator identity and optional ECDSA signature over the metadata commit hash.
pubkey: 33-byte compressed secp256k1 pubkey, hex-encoded. sig: DER-encoded ECDSA signature, hex-encoded (empty string = unsigned). algo: Signing algorithm identifier string.
- __init__(pubkey, sig='', algo='ecdsa-secp256k1')¶
- class pyrxd.glyph.GlyphFt[source]¶
Bases:
objectA minted or transferable FT Glyph.
- __init__(ref, owner_pkh, amount, metadata)¶
- Parameters:
ref (GlyphRef)
owner_pkh (Hex20)
amount (int)
metadata (GlyphMetadata | None)
- Return type:
None
- metadata: GlyphMetadata | None¶
- class pyrxd.glyph.GlyphInspector[source]¶
Bases:
objectParse raw transaction bytes to find Glyph outputs. Pure — no network access.
- classify_glyph_scriptsig(scriptsig)[source]¶
What kind of
glyenvelope, if any, does this scriptSig carry?THE POINT OF THIS METHOD IS THE THIRD ANSWER.
extract_reveal_metadata()returnsNoneboth when there is no glyph here and when there is one it could not parse, and those are opposite facts: the first means “nothing to report”, the second means “something is being published that I cannot read”. Collapsing them makes the blind case look like the empty one, and the blind case is the more confident-sounding of the two.That is not hypothetical. Measured on the mainnet chain for custodian-gate-x7f3.rxd, three of four transactions carry the marker and the reveal parser decoded exactly one — so “this WAVE name was never updated” and “I cannot read WAVE updates” produced identical output, and the wrong one is the reassuring one.
- Returns:
Nonewhen no push equals theglymarker — genuinely not a glyph scriptSig. Otherwise aGlyphEnvelopewhosekindis"payload"(a full token payload),"update"(a partial update — mutated fields only), or"unreadable"(the marker is there and neither reader accepted what followed).- Parameters:
scriptsig (bytes)
- Return type:
GlyphEnvelope | None
- extract_reveal_cbor(scriptsig)[source]¶
The RAW envelope bytes this scriptSig carries, or
None.Selected by exactly the same walk and marker rule as
_parse_reveal_scriptsig(), deliberately: a caller hashing these bytes against a commit’spayload_hashmust hash the push thatextract_reveal_metadata()actually decoded, or the check answers about a different payload than the one on screen. Sharing the selection is what makes the two agree; two walkers here have already drifted once.Returns the bytes undecoded, because the check is over the wire form.
- extract_reveal_metadata(scriptsig)[source]¶
Parse a reveal TX scriptSig to extract CBOR metadata.
scriptSig format:
<sig> <pubkey> <"gly"> <CBOR>. ReturnsNoneif this is not a reveal scriptSig (or if the CBOR is malformed / unrecognised).Catches
Exceptionbroadly because every call site here crosses a trust boundary: scriptSigs from network-fetched txs are attacker- controlled, and the CBOR decoder + push-data walker may raise anything fromValidationErrortocbor2.CBORDecodeErrortoIndexErroron truncated input. ReturningNoneis the contract callers expect.- Parameters:
scriptsig (bytes)
- Return type:
GlyphMetadata | None
- find_glyphs(tx_outputs)[source]¶
Given list of (satoshis, script_bytes) outputs, return detected Glyphs.
Detects NFT singletons, FT locks, mutable NFTs, dMint contract outputs, and the dead pre-0.15.0 container-with-child-ref shape. Plain P2PKH and unrecognised scripts are silently skipped. Commit-output classification lives outside
find_glyphsbecause a commit has no meaningfulrefuntil its reveal lands.A CONTAINER reports as
"nft": its locking script is the NFT singleton, so no script-level classifier can distinguish one, here or in any other implementation. Container-ness is an envelope property — read it from the reveal metadata (GlyphProtocol.CONTAINERinprotocol, surfaced byGlyphMetadata.is_containerandpyrxd.glyph.wave.classify_glyph_metadata()).GlyphScannerdoes this join for you.
- find_reveal_metadata(scriptsigs)[source]¶
Walk every input scriptSig and return the first reveal metadata found.
Returns
(input_index, metadata)for the first input whose scriptSig embeds aglymarker followed by parseable CBOR;Noneif no input does. Distinct fromextract_reveal_metadata()(which checks a single scriptSig) — diagnostic callers want to know which input carried the metadata, and that the inspector looked beyond input 0.- Parameters:
- Return type:
tuple[int, GlyphMetadata] | None
- parse_mint_scriptsig(scriptsig)[source]¶
Decode a dMint mint-claim scriptSig into its 4 canonical pushes.
A V1/V2 dMint mint claim spends the contract UTXO with a scriptSig of the form:
V1 (nonce_width=4): <0x04 nonce(4)> <0x20 inputHash(32)> <0x20 outputHash(32)> <OP_0> → 72 bytes V2 (nonce_width=8): <0x08 nonce(8)> <0x20 inputHash(32)> <0x20 outputHash(32)> <OP_0> → 76 bytes
Where:
nonce— little-endian PoW nonce found by the miner.inputHash—SHA256d(funding_input_locking_script). NOT a preimage half; the on-chain covenant recomputesSHA256(inputHash || outputHash)from these literal pushes.outputHash—SHA256d(OP_RETURN_msg_script at vout[2]).OP_0— the sentinel push the V1/V2 covenant requires.
Verified against mainnet V1 mint
146a4d68…f3cand the V1 mintc9fdcd34…e530.Returns a dict with
nonce_hex,input_hash,output_hash,version_hint("v1"|"v2"|None), andscriptsig_length— orNoneif the scriptSig doesn’t match the canonical 4-push shape.Catches
Exceptionbroadly because every call site crosses a trust boundary: scriptSigs from network-fetched txs are attacker- controlled. Non-mint inputs (P2PKH funding inputs, plain RXD spends, etc.) returnNone.
- class pyrxd.glyph.GlyphMetadata[source]¶
Bases:
objectCBOR payload for a Glyph token.
- __init__(protocol, name='', ticker='', description='', token_type='', main=None, attrs=<factory>, loc='', loc_hash='', decimals=0, image_url='', image_ipfs='', image_sha256='', v=None, dmint_params=None, creator=None, royalty=None, policy=None, rights=None, created='', commit_outpoint='', timelock=None, encrypted_main=None, crypto=None, source_cbor=None, container_refs=(), author_refs=())¶
- Parameters:
name (str)
ticker (str)
description (str)
token_type (str)
main (GlyphMedia | None)
loc (str)
loc_hash (str)
decimals (int)
image_url (str)
image_ipfs (str)
image_sha256 (str)
v (int | None)
dmint_params (DmintCborPayload | None)
creator (GlyphCreator | None)
royalty (GlyphRoyalty | None)
policy (GlyphPolicy | None)
rights (GlyphRights | None)
created (str)
commit_outpoint (str)
timelock (TimelockSpec | None)
encrypted_main (EncryptionMetadata | None)
crypto (CryptoMetadata | None)
source_cbor (bytes | None)
- Return type:
None
- creator: GlyphCreator | None = None¶
- dmint_params: DmintCborPayload | None = None¶
- classmethod for_dmint_ft(ticker, name, decimals=0, description='', image_url='', image_ipfs='', image_sha256='', protocol=None, dmint_params=None)[source]¶
Construct GlyphMetadata for a dMint-marked FT deploy.
Pass
dmint_params(aDmintCborPayload) to embed the dMint configuration object in the token metadata. Indexers and wallets use this to display mining parameters without parsing the contract script.Sets
v=2automatically whendmint_paramsis provided.
- 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.
- policy: GlyphPolicy | None = None¶
- rights: GlyphRights | None = None¶
- royalty: GlyphRoyalty | None = None¶
- to_cbor_dict()[source]¶
Build the dict that gets CBOR-encoded (excluding ‘gly’ marker).
- Return type:
- 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.glyph.GlyphMinter[source]¶
Bases:
objectTwo-phase Glyph minting over an ElectrumX client and an HD wallet.
commit_*broadcasts the commit and returns a persistedPendingMint;reveal_*waits for the commit and broadcasts the reveal. The pair is separable on purpose — a caller can exit between them and resume from the store — andmint_nft()/deploy_ft()compose each pair for callers who do not need to.Usage:
store = JsonFilePendingStore("~/.pyrxd/pending-mints") minter = GlyphMinter(client, wallet, store) result = await minter.mint_nft(metadata)
or, resumably:
pending = await minter.commit_nft(metadata) # persisted, then broadcast ... # crash, reboot, next week pending = store.load(commit_txid) result = await minter.reveal_nft(pending)
- Parameters:
client – an ElectrumX-style client —
await broadcast(hex) -> txidandawait get_transaction_verbose(txid) -> dict. Duck-typed, matchingwait_for_confirmation().wallet – an
HdWallet. Exactly two methods are used —await collect_spendable(client) -> [(utxo, address, privkey)]to fund the commit, andprivkey_for_address(address)to re-derive the reveal’s signing key. Duck-typed likeclient, and deliberately NOT hidden behind a coin-source Protocol:HdWalletis the only real implementer, and a one-implementer Protocol is indirection with nothing on the other side. The two-method surface is small enough to stand in for directly — seeexamples/regtest_quickstart.py, which drives the minter from a single regtest key.store – where the
PendingMintis kept between phases. Required.fee_rate – photons per byte for both transactions.
allow_below_relay_floor – accept a
fee_rateunder the relay floor, for chains whose floor really is lower — a regtest node runs at a tenth of mainnet’s. Applies to the reveal as well, so a minter built this way can finish what it starts. Leave it False against mainnet.min_confirmations – depth required on the commit before the reveal is built. See
DEFAULT_MINT_CONFIRMATIONS— the default is convention.confirmation_timeout_s – how long
reveal_*waits before raisingConfirmationTimeoutError. ThePendingMintsurvives a timeout, so the reveal can be retried.poll_interval_s – seconds between confirmation polls; must be > 0. The default suits a chain whose blocks are minutes apart; a regtest node that mines on demand wants a much smaller value, and until this was exposed there was no way to ask for one — the reveal always slept the full 10s default, so confirmation_timeout_s below that could not take effect until a whole interval had elapsed. Zero is refused: the poll happens before the sleep, so it would busy-loop the server for the whole timeout.
- __init__(client, wallet, store, *, fee_rate=10000, allow_below_relay_floor=False, min_confirmations=1, confirmation_timeout_s=1800.0, poll_interval_s=10.0)[source]¶
- async commit_ft(metadata, *, supply, treasury_pkh=None)[source]¶
Broadcast the commit for a fungible-token deploy with a full premine.
- Parameters:
metadata (GlyphMetadata) – must carry
FT. DMINT is refused — a dMint deploy emits parallel contract UTXOs and belongs toprepare_dmint_deploy().supply (int) – units issued, all placed on the reveal’s token output. Radiant convention is 1 photon = 1 FT unit, so this is also that output’s value, and it must clear pyrxd’s 546-unit decimals-mistake guard (a pyrxd policy, not a chain limit — Radiant’s output floor is 1 photon).
treasury_pkh (Hex20 | bytes | None) – who receives the premine. Defaults to the funding key’s PKH.
- Raises:
ValidationError – on an unsupported protocol mix or a sub-dust supply.
- Return type:
- async commit_nft(metadata, *, owner_pkh=None)[source]¶
Broadcast the commit for an NFT singleton mint.
The
PendingMintis persisted and read back before the broadcast.- Parameters:
metadata (GlyphMetadata) – must carry
NFTand none of the tags whose reveal has a different shape (MUT, WAVE, DMINT) — those are refused rather than committed to a reveal this module cannot build.CONTAINERis supported: a collection’s reveal is this same single-output NFT shape.owner_pkh (Hex20 | bytes | None) – recipient. Defaults to the funding key’s own PKH.
- Raises:
ValidationError – on an unsupported protocol mix.
InsufficientFundsError – if no single UTXO can fund the mint, or if the measured reveal could not pay its fee out of the commit. Both are raised before anything is broadcast.
- Return type:
- async deploy_ft(metadata, *, supply, treasury_pkh=None)[source]¶
commit_ft()thenreveal_ft(). Seemint_nft().- Parameters:
metadata (GlyphMetadata)
supply (int)
- Return type:
- async mint_nft(metadata, *, owner_pkh=None)[source]¶
commit_nft()thenreveal_nft(), waiting for the commit in between.Convenient, not different: the commit is persisted before broadcast here too, because the store is a constructor dependency rather than a per-call keyword. That is the reason it sits on the constructor — a single-call helper cannot forget to pass it.
Note this blocks for as long as the commit takes to confirm. If that is a problem, drive the two phases yourself.
- Parameters:
metadata (GlyphMetadata)
- Return type:
- async reveal_ft(pending, *, fee_rate=None, allow_below_relay_floor=None, allow_overpay=False)[source]¶
Wait for the commit, then broadcast the FT deploy reveal.
Mirrors
reveal_nft(); the reveal’s token output carries the whole premined supply recorded inPendingMint.carrier_value.- Parameters:
pending (PendingMint)
fee_rate (int | None)
allow_below_relay_floor (bool | None)
allow_overpay (bool)
- Return type:
- async reveal_nft(pending, *, fee_rate=None, allow_below_relay_floor=None, allow_overpay=False)[source]¶
Wait for the commit, then broadcast the NFT reveal.
The stored record is re-validated against the commit script before anything is built — see
_assert_payload_still_matches(). On success the record is deleted from the store; on failure it is kept so the reveal can be retried.- Parameters:
pending (PendingMint)
fee_rate (int | None)
allow_below_relay_floor (bool | None)
allow_overpay (bool)
- Return type:
- property store: PendingStore¶
The configured
PendingStore— resume through it after a crash.
- class pyrxd.glyph.GlyphNft[source]¶
Bases:
objectA minted or transferable NFT Glyph.
A CONTAINER (collection) is an ordinary
GlyphNft— same 63-byte locking script, same transfer path. Useis_containerto tell one apart andcontainer_refsto read which collection(s) this token declares membership in.- __init__(ref, owner_pkh, metadata)¶
- Parameters:
ref (GlyphRef)
owner_pkh (Hex20)
metadata (GlyphMetadata | None)
- Return type:
None
- property author_refs: tuple[GlyphRef, ...]¶
Author / issuer tokens this token declares (envelope
byfield).
- property container_refs: tuple[GlyphRef, ...]¶
Containers this token declares membership in (envelope
infield).Advisory: nothing on chain binds a token to a container. What makes a claim checkable is that the container’s ref also appears among the refs of the reveal transaction’s outputs — see
pyrxd.glyph.builder.GlyphBuilder.prepare_container_child_reveal().
- property is_container: bool¶
True when this token is itself a CONTAINER (envelope marker
7).Falsewhen the metadata could not be resolved — absence of evidence. A caller that must distinguish “not a container” from “unknown” should checkmetadata is Nonefirst.
- metadata: GlyphMetadata | None¶
- class pyrxd.glyph.GlyphPolicy[source]¶
Bases:
objectToken behaviour policy flags.
- __init__(renderable=None, executable=None, nsfw=None, transferable=None)¶
- class pyrxd.glyph.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.glyph.GlyphRef[source]¶
Bases:
object36-byte Glyph reference: txid (reversed LE) + vout (4-byte LE).
- 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.
- class pyrxd.glyph.GlyphRights[source]¶
Bases:
objectLicensing and attribution information.
- __init__(license='', terms='', attribution='')¶
- class pyrxd.glyph.GlyphRoyalty[source]¶
Bases:
objectOn-chain royalty hint for secondary-market wallets.
bps: Basis points (100 = 1%, 500 = 5%, max 10000 = 100%). address: Radiant address to receive royalty payments. enforced: Whether wallets should enforce this royalty. minimum: Minimum royalty amount in photons (0 = no minimum). splits: Optional list of (address, bps) pairs for royalty splitting.
The sum of split bps should equal the top-level bps.
- __init__(bps, address, enforced=False, minimum=0, splits=<factory>)¶
- class pyrxd.glyph.GlyphScanner[source]¶
Bases:
objectScan a Radiant address or script_hash for Glyph outputs.
- Parameters:
client – An already-connected ElectrumXClient. The scanner does not own the connection lifecycle; callers should use the client as a context manager and pass it in.
- __init__(client)[source]¶
- Parameters:
client (ElectrumXClient)
- Return type:
None
- async fetch_metadata(ref)[source]¶
The mint envelope for
ref, read off the chain.Noneif it cannot be found.Public because a caller may want a token’s own metadata without wanting its holder’s whole inventory —
pyrxd glyph timelock-revealneeds exactly this, and needs it from the CHAIN rather than from the operator: a CEK checked against a commitment the operator supplied proves only that they typed two matching things.Walks commit-output history to find the reveal that carried the envelope; see the module docstring for why
ref.txidalone is not enough.- Parameters:
ref (GlyphRef)
- Return type:
GlyphMetadata | None
- async scan_address(address)[source]¶
Return all Glyph outputs currently owned at address.
- Parameters:
address (str) – Base58Check-encoded P2PKH address.
- Returns:
Typed Glyph objects.
metadataisNonewhen the reveal transaction cannot be located or carries no readable envelope (see_resolve_reveal_metadata()) — including transfer outputs whose commit-output history is unavailable.- Return type:
- async scan_script_hash(script_hash)[source]¶
Return all Glyph outputs for script_hash.
Fetches UTXOs, raw transactions, and (where available) reveal transaction metadata, then constructs typed GlyphNft / GlyphFt objects.
Concurrency: UTXO raw-tx fetches and reveal-metadata resolutions both run in parallel via
asyncio.gather. Pre-fix (closes ultrareview re-review N17) the reveal-metadata path was inside the per-utxo loop and serialised one round-trip per glyph; for a 100-glyph wallet that meant ~100x the latency of the now- batched version. Metadata is resolved once per distinct ref, so an FT split across many UTXOs costs one resolution, not N.
- class pyrxd.glyph.JsonFilePendingStore[source]¶
Bases:
PendingStoreOne 0600 JSON file per pending mint, in a 0700 directory.
A file per record rather than one shared file: two mints in flight would otherwise contend on a read-modify-write, and a torn merge on this path loses the CBOR bytes of whichever record lost.
save()follows the house atomic-write convention (pyrxd.hd.wallet.save,pyrxd.gravity.watch.escalation._store_state): write a temp file opened 0600 byos.open— not chmod’ed afterwards, which would leave a window at the umask’s mercy —fsyncit, thenos.replaceonto the target. The rename is atomic on the same filesystem, so a reader sees either the old record or the new one, never a partial write. It then re-reads the file and compares before returning, because a write that reported success and landed corrupt is indistinguishable from a good one until the reveal fails, by which point the commit is already on-chain.- delete(commit_txid)[source]¶
Drop the record. Must not raise if it is already gone.
- Parameters:
commit_txid (str)
- Return type:
None
- load(commit_txid)[source]¶
Return the stored record, or raise
PendingMintNotFound.- Parameters:
commit_txid (str)
- Return type:
- save(pending)[source]¶
Persist pending, overwriting any record under the same commit txid.
- Parameters:
pending (PendingMint)
- Return type:
None
- class pyrxd.glyph.MarkAnchor[source]¶
Bases:
objectWhere a transaction sits in the chain, according to some endpoint.
- __init__(txid, height, confirmations, min_confirmations, source, caveat='height reported by the endpoint and NOT verified: pyrxd has no Radiant header, proof-of-work or merkle-inclusion check, so an endpoint that lies about the height moves the point in time this answer is about', height_is_verified=False)¶
- caveat: str = 'height reported by the endpoint and NOT verified: pyrxd has no Radiant header, proof-of-work or merkle-inclusion check, so an endpoint that lies about the height moves the point in time this answer is about'¶
- height_is_verified: bool = False¶
Always
False. There is no Radiant SPV in this codebase; see the module docstring.
- property usable_for_point_in_time: bool¶
Has a block, and is buried to the depth the caller asked for.
- height: int | None¶
Nonewhen the endpoint reports no confirmations — an unmined transaction has no block, and form 2 is unavailable for it by construction rather than by policy.
- class pyrxd.glyph.MintResult[source]¶
Bases:
objectA completed mint — both transactions broadcast.
- ref¶
the token’s permanent
GlyphRef(the commit outpoint — seePendingMint.ref).
- __init__(commit_txid, reveal_txid, ref, reveal_fee, carrier_value, owner_pkh)¶
- class pyrxd.glyph.MutableChainWalk[source]¶
Bases:
objectThe result. Read
completebefore reading anything else.- __init__(ref, steps, tip_txid, tip_vout, tip_proved_unspent, complete, reason='', excluded=())¶
- class pyrxd.glyph.MutableRevealScripts[source]¶
Bases:
objectScripts for a MUT reveal — two inputs and two outputs required.
See
GlyphBuilder.prepare_mutable_reveal()for the transaction shape.refandmutable_refare two DIFFERENT outpoints on the same commit transaction and both must be spent by the reveal.- __init__(ref, nft_script, contract_script, scriptsig_suffix, payload_hash, mutable_ref=None)¶
- class pyrxd.glyph.NftTransferBuild[source]¶
Bases:
objectA signed, un-broadcast NFT transfer.
- Parameters:
tx – the signed
Transactionfee – photons paid, sourced from a plain-RXD input rather than from the singleton — its value crosses the transfer unchanged
ref – the token transferred
to_pkh – recipient’s 20-byte public-key hash
from_address – the wallet address the singleton was held at
has_change –
Falsewhen the whole funding UTXO became the fee. That is an accepted outcome, not a fault — seenft_transfer_funding_bar()— but a caller showing a confirmation prompt should say so.
- __init__(tx, fee, ref, to_pkh, from_address, has_change)¶
- tx: Transaction¶
- class pyrxd.glyph.PendingMint[source]¶
Bases:
objectA broadcast commit whose reveal has not been built yet.
Everything needed to spend the commit output, and nothing secret. The signing key is not a field — it is re-derived from
funding_addressagainst the wallet at reveal time, so persisting this record can never write key material to disk.cbor_bytesis held as bytes, not a hex string. That is the house convention for binary in memory (seeNegotiatedTerms.hashlockinpyrxd.gravity.swap_state), it is the typecbor_byteshands over, and it is whatbuild_reveal_scriptsig_suffix()consumes — so hex would mean converting twice around a value whose exactness is the whole point. Hex appears only at the wire boundary, into_dict().- cbor_bytes¶
the exact payload the reveal scriptSig must push. Losing these makes the commit output permanently unspendable.
- Type:
- carrier_value¶
photons the reveal places on the token output — a dust carrier for an NFT, the whole premined supply for an FT.
- Type:
- __init__(commit_txid, commit_vout, commit_value, commit_script, cbor_bytes, owner_pkh, is_nft, carrier_value, fee_rate, funding_address)¶
- classmethod from_dict(d)[source]¶
Rebuild from
to_dict(), REJECTING an unrecognisedschema_version.Fail-closed on the version the way
SwapRecord.from_dictdispatches on its own: a newer record parsed leniently by older code would yield a reveal built from a misread payload, and the commit output only affords one attempt at being spent correctly.- Parameters:
d (dict)
- Return type:
- property ref: GlyphRef¶
the commit outpoint, not the reveal’s.
prepare_revealembeds this into the reveal’s locking script, and it is whatextract_ref_from_{nft,ft}_scriptreads back.- Type:
The token’s permanent identity
- to_dict()[source]¶
JSON-serialisable form; bytes become hex at this boundary and only here.
to_dict/from_dictrather thanto_json/from_json: that is what every durable type in this repo uses (SwapRecord,NegotiatedTerms,BtcHtlcLocator,EscalationState), leaving the caller to choose the serializer.- Return type:
- exception pyrxd.glyph.PendingMintNotFound[source]¶
Bases:
RxdSdkErrorNo
PendingMintis stored under the requested commit txid.Module-local rather than in
pyrxd.security.errors, matchingWaveNameNotFoundandRxinDexerNotFound.
- class pyrxd.glyph.PendingStore[source]¶
Bases:
ABCWhere a
PendingMintlives between the commit and the reveal.Required, not optional — see the module docstring. Two implementations ship:
JsonFilePendingStore(use this) andUnsafeNullPendingStore(an explicit opt-out that a caller has to name).Implementations must make
save()durable before it returns.GlyphMinter.commit_nft()reads the record straight back throughload()and compares it before broadcasting, so a store that silently drops the write is caught there rather than one crash later.- abstractmethod delete(commit_txid)[source]¶
Drop the record. Must not raise if it is already gone.
- Parameters:
commit_txid (str)
- Return type:
None
- durable: ClassVar[bool] = True¶
Whether
save()actually persists.GlyphMinterskips its read-back verification (and warns) when this isFalse; a store that sets itFalsewhile claiming to persist defeats that check.
- abstractmethod list_pending()[source]¶
Commit txids with a stored record — the resume list after a crash.
- abstractmethod load(commit_txid)[source]¶
Return the stored record, or raise
PendingMintNotFound.- Parameters:
commit_txid (str)
- Return type:
- abstractmethod save(pending)[source]¶
Persist pending, overwriting any record under the same commit txid.
- Parameters:
pending (PendingMint)
- Return type:
None
- class pyrxd.glyph.PowPreimageResult[source]¶
Bases:
objectThe 64-byte PoW preimage plus the two script hashes a miner must push.
The covenant binds the PoW hash AND the scriptSig pushes together: it recomputes
H2 = SHA256(scriptSig_inputHash || scriptSig_outputHash)and folds that into the same hash the miner solved. Diverging the preimage from the scriptSig pushes is a silent on-chain rejection — seedocs/solutions/runtime-errors/dmint-v1-mint-scriptsig-shape.mdfor the prior incident that motivated returning all three values from a single helper.- Parameters:
preimage – 64-byte SHA256d PoW preimage; feeds
mine_solution.input_hash –
SHA256d(input_script)— push asscriptSig_inputHash.output_hash –
SHA256d(output_script)— push asscriptSig_outputHash.
- __init__(preimage, input_hash, output_hash)¶
- class pyrxd.glyph.RoyaltyPayout[source]¶
Bases:
objectOne royalty recipient and the photons owed to them.
- Parameters:
address – the recipient’s Radiant address, verbatim from the token’s
GlyphRoyalty.pkh – the 20-byte public-key hash decoded from
address. Decoding happens once, inroyalty_payouts(), so a malformed address fails there rather than producing an unspendable output.photons – the amount to pay. Always
>= 1— a payout that rounds to zero is dropped rather than emitted, so no caller has to handle a zero-value output.
- __init__(address, pkh, photons)¶
- class pyrxd.glyph.RxinDexerClient[source]¶
Bases:
objectThin wrapper over
ElectrumXClientfor RXinDexer extension RPCs.Methods are grouped by RPC namespace (
wave_*,glyph_*,swap_*). Each wraps a single RPC call, parses the response into a typed result, and converts transport / parse failures intoRxinDexerErrorsubclasses.The
pyrxd.glyph.wave.WaveResolveris built on top of this client and is the canonical entry-point for WAVE name resolution in higher-level applications.- __init__(client)[source]¶
- Parameters:
client (ElectrumXClient)
- async glyph_get_balance(address, token_ref=None)[source]¶
glyph.get_balance— fungible-token balance for an address.Pass token_ref to scope the query to a specific token; without it, the indexer returns all FT balances the address holds.
- async glyph_get_recent(limit=100, cursor=None, token_type=None)[source]¶
glyph.get_recent— newest-deployed tokens, newest-first.Across every type by default; pass
token_type(1=FT, 2=NFT, 3=DAT, 4=DMINT, 5=WAVE, 6=Container, 7=Authority) to filter. Returns{"tokens": [...], "next_cursor": str | None}.
- async glyph_get_tokens_by_type(token_type, limit=100, cursor=None, order='ref')[source]¶
glyph.get_tokens_by_type— tokens of one type.order="recent"= newest-deployed first (v4 index);order="ref"(default) = legacy stable ref-hash order. Cursors must not be reused across a change oforder. Returns{"tokens": [...], "next_cursor": str | None}.
- async swap_get_orders(base_ref=None, quote_ref=None, *, limit=50, offset=0)[source]¶
swap.get_orders— RXinDexer’s confirmed swap-order query.With only
base_ref(txid_voutor 72-hex, perglyph_api.py::_parse_ref): open orders offering that token, newest-index-first, server-sidelimitclamped to 200. With BOTHbase_refandquote_ref: the{bids, asks}orderbook for that exact pair instead of a flat list. There is NO filter for “orders wanting token X” alone (no symmetric quote-only index exists server side as of this 2026-07-05 verification) — callers needing that must raise rather than approximate it by scanning every base ref.
- async wave_check_available(name)[source]¶
True if name is not yet registered on-chain. Takes the BARE LABEL.
THE ANSWER IS A DICT, AND EVERY DICT IS TRUTHY. Upstream’s
check_available(electrumx/server/wave_index.py) always returns a mapping carrying anavailablekey —{'available': False, 'ref': ..., 'name': ...}for a name that is TAKEN,{'available': False, 'error': ...}for one that failsvalidate_wave_name,{'available': True, ...}when it is genuinely free. This method didreturn bool(result), so it answered True for every one of those — reporting a registered name as available, which is the fail-open direction for a method whose entire job is to stop a caller minting over someone else’s name.Like
wave.resolve, the RPC wants the label:validate_wave_nameruns first and.is not in itsWAVE_CHARS. Callers passing"alice.rxd"were answered with an error dict — which the oldbool()then reported as available. Stripping to the label is done bypyrxd.glyph.wave.WaveResolver.check_available(); a bare label is what this method expects.
- async wave_resolve(name)[source]¶
Raw
wave.resolvecall. Returns the indexer’s dict response, orNoneif the name is not registered. Higher-level callers should usually usepyrxd.glyph.wave.WaveResolver.
- async wave_reverse_lookup(address)[source]¶
All WAVE names whose OWNER holds the token at address, qualified (
alice.rxd).THE INDEXER TAKES A SCRIPTHASH, NOT AN ADDRESS. RXinDexer’s
reverse_lookup(scripthash: bytes)accepts a 32-byte Electrum scripthash (or its 11-byte hashX) and indexes owners by it. This method sent the base58 address and was answered with{"error": "non-hexadecimal number found in fromhex() arg at position 2"}— measured againstelectrumx.radiantcore.org2026-09-16, confirmed inwave_index.pyupstream. And it returns a list of DICTS (ref,name,full_name,status,zone,owner), not a list of names, so even a lucky answer would have been rendered asstr(dict). Both halves are fixed here.Entries flagged
status == "expired"are dropped: upstream keeps a lapsed name listed so the owner can see it needs renewal, but it no longer RESOLVES, and this method’s contract is names that resolve.
- exception pyrxd.glyph.RxinDexerError[source]¶
Bases:
ExceptionBase class for RXinDexer-specific errors.
- exception pyrxd.glyph.RxinDexerNotFound[source]¶
Bases:
RxinDexerErrorA lookup returned no result (name not registered, token unknown, etc.).
- class pyrxd.glyph.TransferReceipt[source]¶
Bases:
objectWhat a broadcast transfer actually did.
Deliberately reports the broadcast txid and the fee, because the fee is the number a caller cannot recover afterwards without re-fetching and re-deriving.
- amount¶
- fee¶
- ref¶
- to_pkh¶
- txid¶
- class pyrxd.glyph.UnsafeNullPendingStore[source]¶
Bases:
PendingStoreDiscards everything. Named
Unsafebecause it is.The escape hatch for callers who genuinely do not want a file written — a throwaway regtest run, a test, an embedding application with its own storage that has not been wrapped in a
PendingStoreyet.With this store a crash between the commit broadcast and the reveal leaves the commit output permanently unspendable, along with its value. Constructing it emits a
UserWarningso the choice shows up in logs rather than only in the source.- delete(commit_txid)[source]¶
Drop the record. Must not raise if it is already gone.
- Parameters:
commit_txid (str)
- Return type:
None
- durable: ClassVar[bool] = False¶
Whether
save()actually persists.GlyphMinterskips its read-back verification (and warns) when this isFalse; a store that sets itFalsewhile claiming to persist defeats that check.
- load(commit_txid)[source]¶
Return the stored record, or raise
PendingMintNotFound.- Parameters:
commit_txid (str)
- Return type:
- save(pending)[source]¶
Persist pending, overwriting any record under the same commit txid.
- Parameters:
pending (PendingMint)
- Return type:
None
- exception pyrxd.glyph.V2UnvalidatedWarning[source]¶
Bases:
UserWarningRetained warning category for V2 dMint code paths.
HISTORY: V2 dMint was once quarantined behind this warning because it had never been exercised against live consensus. That is no longer true — the canonical-Photonic V2 redesign is byte-matched to upstream and consensus- validated on radiant-core v3.1.1 regtest AND Radiant mainnet (3.1.2): the first V2 dMint deploy + PoW mint confirmed on mainnet (deploy
95335028…bb16fb09, mint1239f64a…e0cd6c67; #219). The per-call warning is therefore no longer emitted.The class is kept (not deleted) so any downstream
warnings.simplefilter(…, V2UnvalidatedWarning)filters remain importable. V2 is still pre-external- audit — that caveat lives in the README / threat-model, the same level as V1, not in a per-call warning.
- class pyrxd.glyph.WaveAttrs[source]¶
Bases:
objectParsed WAVE attrs dict, mirroring the on-chain Photonic shape.
- __init__(name, domain, target, target_type='address', expires=None)¶
- expires: int | None = None¶
CBOR
attrs.expires, when the record carried one.MODELLED BECAUSE IT WAS BEING DROPPED. from_dict read four keys and ignored the rest, so a real mainnet record round-tripped [domain, expires, name, target, target_type] back out as [domain, name, target, target_type] - silently, and for the one field that decides whether a name was even held at a given time.
NOT AUTHORITATIVE, and callers must not read it as an expiry. Photonic states in its own source that “the indexer is the authority on renewals … the attrs.expires written here is display-level”: real expiry follows from treasury payments this type never sees. It is carried so a round trip is lossless, not so anything can be concluded from it.
- class pyrxd.glyph.WaveIdentityVerdict[source]¶
Bases:
objectWhat can be said about a name and a mark. Read
formfirst.- __init__(form, ref, binding_source, binding_verified, target_at_height, height, provisional, expiry, degraded_reason, caveat)¶
- exception pyrxd.glyph.WaveNameNotFound[source]¶
Bases:
WaveResolverErrorRaised when the requested name does not exist in the indexer.
- class pyrxd.glyph.WaveRecord[source]¶
Bases:
objectA full WAVE registration, as returned by
wave.resolve.The exact response shape from RXinDexer is documented at https://github.com/Radiant-Core/RXinDexer; this class normalizes the minimum fields a swap coordinator needs.
- __init__(name, target, target_type, claim_txid, block_height, ref='', status='')¶
- classmethod from_indexer_response(data)[source]¶
Build a WaveRecord from the JSON-RPC response.
Tolerant of field naming — RXinDexer’s response wraps things in
attrsor surfaces them top-level depending on version. Tries both shapes before erroring.Measured shape from the public indexer, 2026-09-16:
{"name": "custodian-gate-x7f3", "ref": "<reveal_txid>_0", "target": "14Xm…", "zone": {"address": "14Xm…"}, "owner": "<11-byte hashX hex>", "available": false, "canonical": true, "has_duplicates": false, "expires": 1850744391, "status": "active"}
namecomes back as the bare label; it is re-qualified here so callers see the samealice.rxdthey asked for.claim_txidfalls back to the ref’s txid, which IS the registration transaction.- Parameters:
- Return type:
- ref: str = ''¶
"<reveal_txid>_0"). A WAVE ref is the REVEAL outpoint — upstream’s own comment: “a WAVE ref is the reveal outpoint (reveal_txid:0)” — so its txid is the mint a mutable-chain walk starts from.- Type:
The indexer’s ref string, verbatim (RXinDexer
- property reveal_txid: str¶
The txid half of
ref, or""if the indexer gave no usable ref.Accepts
txid_vout(RXinDexer) andtxid:vout. Anything that is not 64 hex characters before the separator is reported as absent rather than passed on to a network fetch that would then fail somewhere less legible.
- class pyrxd.glyph.WaveResolver[source]¶
Bases:
objectHigh-level WAVE name resolver — composes
RxinDexerClient.Accepts either an
ElectrumXClient(auto-wraps inRxinDexerClient) or an existingRxinDexerClient. The latter is preferred when you have other indexer use cases (Glyph metadata lookups, Swap state, etc.) so the same client is shared.All methods raise
WaveResolverError(a subclass ofRxinDexerError) on transport / parse failures. Name-not-found raisesWaveNameNotFoundso callers can distinguish “does not exist” from “indexer is down”.- __init__(client)[source]¶
- Parameters:
client (ElectrumXClient | RxinDexerClient)
- async check_available(name)[source]¶
Return True if name is not yet registered.
SENDS THE LABEL, NOT THE QUALIFIED NAME — the same rule
resolve()documents.resolvewas corrected in #695 and this twin was left sending"alice.rxd", whichvalidate_wave_namerefuses; the refusal came back as an{"error": ...}dict, which the client then reported as available. Fixing one caller of a shared rule and not the other is how that gap survived.
- async resolve(name)[source]¶
Look up a qualified WAVE name (e.g.
"alice.rxd").Raises
WaveNameNotFoundif the name is not registered. RaisesWaveResolverErroron transport / parse failures.THE INDEXER WANTS THE LABEL, NOT THE QUALIFIED NAME. RXinDexer’s
resolve()runsvalidate_wave_namebefore anything else, and.is not in itsWAVE_CHARS, so"alice.rxd"is answered with{"error": "Invalid character: ."}— measured against the publicelectrumx.radiantcore.orgindexer 2026-09-16 and confirmed inelectrumx/server/wave_index.pyupstream. This method sent the qualified name, so it never resolved a real name against the canonical indexer. The label is sent now, and anerrorkey in the answer is raised rather than parsed as a record.- Parameters:
name (str)
- Return type:
- exception pyrxd.glyph.WaveResolverError¶
Bases:
RxinDexerErrorRaised when a WAVE name resolution call fails for any reason. Subclass of RxinDexerError — catch either to handle indexer failures.
- pyrxd.glyph.build_authority_metadata(issuer, *, name='Authority Token', scope=None, permissions=(), expires=None, revocable=True, description='')[source]¶
Build an authority token’s metadata (
p = [NFT, AUTHORITY]).Mirrors Photonic
createAuthority. Mint it like any NFT; it becomes an authority by the10marker, not by a special script.- Parameters:
issuer (str) – the issuing identity — an address or pubkey. Required and non-empty: an authority naming no issuer says nothing about who is vouching, and
validate_authority()rejects it on read.expires (str | None) – ISO-8601 timestamp. A value without a timezone is read as UTC by
is_authority_expired().name (str)
scope (str | None)
revocable (bool)
description (str)
- Raises:
ValidationError – issuer is empty, or expires is unparseable — caught here rather than at read time, because a mint is irreversible and an unparseable expiry silently reads as “never expires”.
- Return type:
- pyrxd.glyph.build_burn_proof_script(token_ref, *, amount=None, burn_reason=None)[source]¶
Build the
OP_RETURNburn-proof output script.Give this output 0 photons: it is unspendable, and any value on it is destroyed along with the token.
- Parameters:
- Raises:
ValidationError – amount is negative, or the encoded proof exceeds the CBOR cap.
- Return type:
- pyrxd.glyph.build_dmint_code_script(params)[source]¶
Build the V2 dMint code bytecode (Part A + powHashOp + Part B + Part C).
- Parameters:
params (DmintDeployParams)
- Return type:
- pyrxd.glyph.build_dmint_contract_script(params)[source]¶
Build the full V2 dMint output script: state + OP_STATESEPARATOR + code.
Byte-identical to the canonical Photonic
dMintScriptfor the same parameters (validated against golden vectors in tests/test_dmint_v2_canonical.py, and consensus-proven on regtest + mainnet).- Parameters:
params (DmintDeployParams)
- Return type:
- pyrxd.glyph.build_dmint_state_script(params)[source]¶
Build the 10-item V2 dMint state script (before OP_STATESEPARATOR).
Layout (canonical redesign §4.2):
height(minimal) | d8:contractRef(36B) | d0:tokenRef(36B) | maxHeight | reward | algoId | daaMode | targetTime | lastTime(4B LE) | target(minimal)
heightandtargetuse minimal pushes (variable width) so the state script is MINIMALDATA-compliant from height 0 / target MAX onward — the old fixed04 [LE4]height push was rejected by radiantd’s MINIMALDATA mempool policy on mainnet.lastTimestays a 4-byte push (Unix timestamps are always 4-byte minimal), which simplifies Part C’s04 || NUM2BIN(4, locktime)reconstruction.- Parameters:
params (DmintDeployParams)
- Return type:
- pyrxd.glyph.build_dmint_v1_ft_output_script(miner_pkh, token_ref)[source]¶
Build the 75-byte P2PKH-wrapped FT output that a V1 mint produces.
Layout (docs/dmint-research-mainnet.md §4 vout[1]):
76 a9 14 <pkh:20> OP_DUP OP_HASH160 PUSH20 pkh 88 ac OP_EQUALVERIFY OP_CHECKSIG (25-byte P2PKH prologue) bd OP_STATESEPARATOR d0 <tokenRef:36> OP_PUSHINPUTREF tokenRef (37 bytes) de c0 e9 aa 76 e3 12-byte covenant fingerprint (`_V1_FT_OUTPUT_EPILOGUE`) 78 e4 a2 69 e6 9d ────────────────────── Total: 75 bytes
This is the FT-bearing reward output — the V1 contract’s
OP_CODESCRIPTHASHVALUESUM_OUTPUTS OP_NUMEQUALVERIFYat epilogue offset 168 sums photons under this codescript and requires the total to equal the contract’srewardfield. Producing a plain P2PKH instead breaks FT conservation and the network rejects the mint.- Raises:
ValidationError –
miner_pkhis not 20 bytes.- Parameters:
- Return type:
- pyrxd.glyph.build_dmint_v1_mint_preimage(contract_utxo, funding_utxo, unsigned_tx)[source]¶
Build the V1 mining preimage AND scriptSig hashes for an unsigned mint tx.
The V1 covenant binds the PoW preimage to:
The contract input’s outpoint txid + the contract ref (so a nonce mined for one contract slot can’t be replayed against another)
The miner’s funding-input locking script (so the miner cannot substitute a different funding source after finding a nonce)
The OP_RETURN msg output script at vout[2] (Photonic’s mainnet-canonical layout; the covenant computes outputHash = SHA256d(this script))
Layout (matches
build_pow_preimage()):preimage = SHA256(txid_LE || contractRef) || SHA256(SHA256d(input_script) || SHA256d(output_script)) input_hash = SHA256d(input_script) ← scriptSig push output_hash = SHA256d(output_script) ← scriptSig pushCallers feed
preimagetomine_solution()and passinput_hash+output_hashtobuild_mint_scriptsig().- Parameters:
contract_utxo (DmintContractUtxo) – The V1 contract UTXO being spent.
funding_utxo (DmintMinerFundingUtxo) – The plain-RXD UTXO providing reward + fee.
unsigned_tx (Any) – The unsigned
Transactionfrombuild_dmint_mint_tx()— vout[2] is required to be the OP_RETURN msg output (mainnet-canonical 4-output shape).
- Returns:
PowPreimageResultcarrying the preimage and the two script hashes that the scriptSig must push for the covenant to accept the mint.- Raises:
ValidationError –
unsigned_txhas fewer than 4 outputs (no OP_RETURN at vout[2]) OR vout[2] is not actually an OP_RETURN script. Build the tx viabuild_dmint_mint_tx()with a non-emptyop_return_msg; skipping that produces a 3-output tx, and hand-building a 4-output tx with a different vout[2] would silently bind the preimage to wrong bytes (the on-chain covenant would then reject after a successful mine — wasting the mining work).- Return type:
- pyrxd.glyph.build_dmint_v2_mint_preimage(contract_utxo, funding_utxo, output_script)[source]¶
Build the V2 mining preimage AND scriptSig hashes.
V2 analog of
build_dmint_v1_mint_preimage(). The preimage shape (and the on-chain covenant’s H1/H2 binding logic) is identical to V1 — V2 inherits the output-validation block via_PART_C=_V1_EPILOGUE_SUFFIX[18:]. The only V1/V2 differences at the mint-tx level are the nonce width (8 bytes for V2 vs 4 for V1, a parameter ofbuild_mint_scriptsig()) and the absence of the Photonic-Walletop_return_msgconvention in V2.Layout (matches
build_pow_preimage()):preimage = SHA256(txid_LE || contractRef) || SHA256(SHA256d(input_script) || SHA256d(output_script)) input_hash = SHA256d(input_script) ← scriptSig push output_hash = SHA256d(output_script) ← scriptSig pushUnlike the V1 helper, this function takes
output_scriptas an explicit argument. V2 has no canonical “OP_RETURN msg at vout[2]” convention (that’s Photonic-Wallet’s V1 layout); the V2 covenant binds outputHash to whatever bytes the caller chooses to push. Callers selectingoutput_scriptshould pick one of the actual transaction outputs and document the binding in their own code.Note
This helper closes the audit’s security-H1 finding (no V2 analog of
build_dmint_v1_mint_preimageleft V2 callers one careless script-mismatch away from reproducing the M1 bug pattern). V2 is consensus-proven on regtest + mainnet (#219).- Parameters:
contract_utxo (DmintContractUtxo) – The V2 contract UTXO being spent. Its
state.is_v1MUST beFalse— passing a V1 UTXO is a bug.funding_utxo (DmintMinerFundingUtxo) – The plain-RXD UTXO providing reward + fee.
output_script (bytes) – The output-script bytes to bind into the preimage. V2 has no canonical convention; pick a transaction output the caller cares about (e.g. an OP_RETURN identifier, or the reward output’s locking script).
- Returns:
PowPreimageResultwith the preimage and the two script hashes the scriptSig must push.- Raises:
ValidationError – V1 contract UTXO passed by mistake, or an empty
output_script.- Return type:
- pyrxd.glyph.build_mint_scriptsig(nonce, input_hash, output_hash, *, nonce_width=8)[source]¶
Build the scriptSig a miner includes in the contract-spend input.
- Format (SHA256d):
V2 (nonce_width=8):
<0x08> <nonce:8B> <0x20> <inputHash:32B> <0x20> <outputHash:32B> <0x00>→ 76 bytes V1 (nonce_width=4):<0x04> <nonce:4B> <0x20> <inputHash:32B> <0x20> <outputHash:32B> <0x00>→ 72 bytes
The V1 layout is documented in docs/dmint-research-mainnet.md §4 (vin[0] of the mainnet mint trace at
146a4d68…f3c). Same shape as V2, differing only in nonce width and corresponding push opcode.The hashes pushed here MUST equal
PowPreimageResult.input_hashandoutput_hashfrom the samebuild_pow_preimage()call that produced the preimage the miner solved. The on-chain covenant recomputesSHA256(input_hash || output_hash)from these pushes and folds that into the PoW hash — diverging them silently produces amandatory-script-verify-flag-failedrejection after a successful mine.- Parameters:
nonce (bytes) – nonce_width-bytes nonce (found during mining).
input_hash (bytes) – 32-byte
SHA256d(input_script)fromPowPreimageResult.output_hash (bytes) – 32-byte
SHA256d(output_script)fromPowPreimageResult.nonce_width (Literal[4, 8]) – 4 for V1 contracts, 8 for V2. Keyword-only and
Literal[4, 8]so a stray positional value is a type error rather than a silent V1/V2 confusion. Default 8 preserves pre-V1-support behavior.
- Return type:
- pyrxd.glyph.build_mutable_nft_script(mutable_ref, payload_hash)[source]¶
Build the 175-byte mutable NFT output script.
- Layout: PUSH32 <payload_hash> OP_DROP OP_STATESEPARATOR
OP_PUSHINPUTREFSINGLETON <mutable_ref:36> <102-byte body>
- pyrxd.glyph.build_mutable_scriptsig(operation, cbor_bytes, contract_output_index, ref_hash_index, ref_index, token_output_index)[source]¶
Build the scriptSig for spending a mutable NFT contract input.
- The mutable NFT script expects the scriptSig stack (bottom→top):
gly_marker | cbor_payload | operation | contract_output_index | ref_hash_index | ref_index | token_output_index
- Parameters:
operation (Literal['mod', 'sl']) –
"mod"(modify — update payload hash) or"sl"(seal — burn the mutable contract).cbor_bytes (bytes) – CBOR-encoded metadata for the new state.
contract_output_index (int) – Output index of the mutable contract in the tx.
ref_hash_index (int) – Index into the refdatasummary for this token.
ref_index (int) – Index of the singleton ref in token output data.
token_output_index (int) – Output index of the token in the tx.
- Return type:
- pyrxd.glyph.build_pow_preimage(txid_le, contract_ref_bytes, input_script, output_script)[source]¶
Build the PoW preimage AND the two script hashes the scriptSig must push.
preimage[0..32] = SHA256(txid_LE || contractRef) preimage[32..64] = SHA256(SHA256d(inputScript) || SHA256d(outputScript))
The covenant pulls
inputHashandoutputHashfrom the scriptSig pushes (not from the preimage halves) and recomputes the second SHA256 on-chain. Returning all three values here forces callers to feed both sites from the same source — splitting the helper into “preimage builder” and “scriptSig builder” with independently-recomputed hashes is what produced the M1 covenant-rejection bug.- Parameters:
- Returns:
PowPreimageResultwithpreimage,input_hash,output_hash.- Return type:
- pyrxd.glyph.build_reveal_unlock_template(private_key, scriptsig_suffix)[source]¶
Unlocking template for a Glyph reveal input:
<sig> <pubkey>+ the CBOR suffix.The commit script runs
OP_HASH256 <payload_hash> OP_EQUALVERIFYand then a standard P2PKH tail, so the reveal’s scriptSig is a normal P2PKH unlock with the ``’gly’``+CBOR push sequence appended.This was copy-pasted into all three example scripts (as
glyph_reveal_unlock,ft_reveal_unlock_templateand_glyph_reveal_unlock) and once more intopyrxd.cli.glyph_helpers. Each copy restated the estimated unlocking length;REVEAL_SIG_PREFIX_BYTESis imported here instead, because a copy that drifted low would make the reveal fee guard under-estimate and pass — which strands the commit.
- pyrxd.glyph.build_wave_metadata(*, qualified_name, target, target_type='address', description='', allow_confusable=False)[source]¶
Construct a Photonic-compatible WAVE
GlyphMetadata.- Parameters:
qualified_name (str) – e.g.
"alice.rxd"— split into name + domain.target (str) – the address (or other identifier) the name resolves to.
target_type (str) –
"address"by default; other values are reserved for future schemas (e.g."cross_chain").description (str) – optional human-readable description; stored as top-level
descin CBOR (NOT insideattrs).allow_confusable (bool)
- Return type:
The returned metadata has protocol
[NFT, MUT, WAVE]and anattrsdict matching the Photonic on-chain shape — pass it throughencode_payload()and thenGlyphBuilder.prepare_wave_reveal()to construct the actual reveal transaction.The top-level
namefield onGlyphMetadatais intentionally left empty: validation inprepare_wave_revealprefersattrs.name, and emitting both would create ambiguity if they ever disagree.
- pyrxd.glyph.classify_glyph_metadata(metadata)[source]¶
Return the highest-specificity protocol classification for a metadata payload.
Examples
[NFT, MUT, WAVE]→"wave"(when attrs.name present)[NFT, MUT, WAVE]without attrs.name →"mut"(legacy, won’t resolve)[NFT, MUT, CONTAINER]→"container"[NFT, MUT]→"mut"[NFT, AUTHORITY]→"authority"[NFT, ENCRYPTED, TIMELOCK]→"timelock"[NFT, ENCRYPTED]→"encrypted"[NFT]→"nft"[FT, DMINT]→"dmint"[FT]→"ft"[DAT]→"dat"The string mirrors
GlyphOutput.glyph_typevalues where applicable, with extensions for the metadata-only types that scripts alone can’t distinguish (WAVE/CONTAINER/ENCRYPTED/TIMELOCK/AUTHORITY share script templates with MUT/NFT, and DAT is data-only).Ordering is highest-specificity-first: TIMELOCK is checked before ENCRYPTED (TIMELOCK requires ENCRYPTED per the protocol rules in
types, so a timelocked token always carries both).- Parameters:
metadata (GlyphMetadata)
- Return type:
- pyrxd.glyph.compute_next_target_asert(current_target, last_time, current_time, target_time, half_life)[source]¶
Compute next ASERT-lite target (mirrors the redesigned on-chain bytecode).
The redesign replaced OP_LSHIFT/OP_RSHIFT (which Radiant evaluates as a big-endian bit-string shift — wrong on the LE target encoding) with an unrolled 4-step OP_2MUL/OP_2DIV loop with a per-step overflow cap:
drift = trunc((current_time - last_time - target_time) / half_life) # clamp [-4,+4] drift > 0: repeat |drift|x: target = MAX_TARGET if target > MAX/2 else target*2 drift < 0: repeat |drift|x: target = target // 2 minimum target is 1
The per-step cap matches the miner’s
newTarget = min(MAX, oldTarget<<drift)clamp-at-MAX semantics (a naivetarget << driftwould overshoot MAX).Note
V2-only DAA. V1 has no DAA (fixed difficulty).
- pyrxd.glyph.compute_next_target_linear(current_target, last_time, current_time, target_time)[source]¶
Compute next linear/LWMA target (mirrors the redesigned on-chain bytecode).
Divide-first with caps so the on-chain OP_MUL never overflows int64:
timeDelta_capped = max(0, min(current_time - last_time, 4 * target_time)) target_capped = min(current_target, MAX_TARGET // 4) new_target = min(MAX_TARGET, (target_capped // target_time) * timeDelta_capped) minimum target is 1
The MAX/4 target cap means LWMA contracts cannot have a difficulty floor below 4 (
target <= MAX_TARGET/4). The 0-floor ontimeDeltamirrors the on-chainOP_0 OP_MAX(Radiant-Core/Photonic-Wallet#2): a backwards-clock block (locktime earlier than the previous mint) gives a negative delta that would otherwise underflow the on-chain int64 multiply.Note
V2-only DAA.
- pyrxd.glyph.difficulty_to_target(difficulty, algo=DmintAlgo.SHA256D)[source]¶
Convert difficulty to PoW target.
- pyrxd.glyph.extract_wave_attrs(cbor_data)[source]¶
Pull
WaveAttrsout of a decoded CBOR payload, if present.Returns
Nonefor non-WAVE payloads or WAVE payloads using only the legacy top-levelnameshape (those exist on-chain but RXinDexer won’t index them).
- async pyrxd.glyph.find_dmint_contract_utxos(client, *, token_ref, initial_state=None, limit=None, min_confirmations=1)[source]¶
Discover live V1 dMint contract UTXOs for a given
token_ref.Two call shapes:
Fast path — pass
initial_state. The function rebuilds each contract’s expected initial codescript locally (contractRef[i] = (commit_txid, i+1),tokenRef = token_ref), computes its scripthash inline, and asks the server for the UTXO at that scripthash. Oneget_utxoscall per contract. Use this shape immediately after deploy to verify all N contracts went live, or any time the caller has the deploy params handy.Walk-from-reveal fallback — omit
initial_state. The function fetches the deploy commit, derives the FT-commit hashlock’s scripthash, queries history for the reveal txid, then fetches the reveal and extracts every fresh V1 contract output whosetokenRefmatches. Slower (3+ extra round-trips) but works on any live token where you only know thetoken_ref.
Both shapes apply the same security S2 cross-check: for each candidate UTXO returned, the source transaction is fetched and verified to have
txid()matching the server’stx_hash, and its output script byte-equal to the script the server claimed. Defends against a malicious or buggy ElectrumX serving altered bytes (mirrorsfind_dmint_funding_utxo()’s round-4 defense).The fallback path returns fresh contracts only — UTXOs that have been mined from at least once are skipped (their state advanced and their scripthash drifted; following the spend chain forward to locate the current head is filed as deferred work).
- Parameters:
client (Any) – An open
pyrxd.network.electrumx.ElectrumXClient.token_ref (GlyphRef) – The token’s permanent 36-byte ref (the deploy commit’s vout-0 outpoint, LE-reversed). Equivalently:
GlyphRef(txid=commit_txid, vout=0).initial_state (DmintV1ContractInitialState | None) – If supplied, fast-path. If
None, walk from the deploy reveal.limit (int | None) – If supplied, cap the result list at this many contracts.
Nonereturns all available.min_confirmations (int) – Skip UTXOs younger than this many blocks. Default 1 (require at least 1 confirmation).
- Returns:
A list of
DmintContractUtxofor each currently-unspent contract whose script verified S2.- Raises:
ValidationError – Inputs malformed (token_ref must point at
vout=0); or initial_state has out-of-range fields.NetworkError – Propagated from the ElectrumX client.
- Return type:
- pyrxd.glyph.fold_chain(walk, *, through_index=None)[source]¶
Replay a walk’s updates onto the mint, shallow-merging
attrs.THE RULE: an update that OMITS a field leaves that field UNCHANGED. Decided in
docs/solutions/design-decisions/wave-update-fold-omission-means-unchanged.md, and the argument is that deletion is not representable - Photonic’sfilterAttrsdropsnull/undefinedbefore merging, so if omission meant clear a field could be destroyed only by accident and never on purpose. Measured, the two candidate rules disagree on 3 of the 7 real chains on mainnet, and only aboutexpires.NEW SEMANTICS, NOT A PORT. Photonic computes only CURRENT state, by merging the mint with the LATEST envelope, ordered by an index’s array - and its stored row is path-dependent, so two of its wallets can disagree about one name. This replays every step in spend order, which is deterministic where the reference is not. It agrees with the reference on all 7 observed chains.
VALUES ARE PRESERVED, NOT STRINGIFIED, and that reversed an earlier decision here. This used to normalise every value to
strbecause the two readers disagreed on type: a mint arrived already stringified whiledecode_update_payloadreturned raw CBOR, soexpireswas'1849006310'from one and1849006310from the other.They no longer disagree.
_decode_attr_valuenow preserves scalars and scalar lists on the mint side, because the blanketstr()was not merely lossy — it INVERTED meaning: an authority token’srevocable: falsebecame the string'False', which is truthy, so a NON-revocable authority read back as revocable, andpermissions: ['mint']became"['mint']", losing every entry. Measured on the mainnet WAVE chain, both readers now returnintforexpires.So stringifying here would re-introduce that inversion one layer down, in the folded record a consumer actually reads. The premise the normalisation rested on is gone, and keeping it would turn a fixed bug back on for anything that folds. Keys stay strings —
_as_attrsalready drops non-string keys, for the collision reasonpayload.pygives.- Parameters:
through_index (int | None) – fold only the first N+1 steps.
Nonefolds all of them.walk (MutableChainWalk)
- Return type:
- pyrxd.glyph.judge_name_at_mark(*, ref, binding_source, anchor, walk, step_heights)[source]¶
Compose an anchor and a completed walk into a form-2 verdict, or degrade to form 1.
Pure: every network answer is already in
anchor,walkandstep_heights, so each degrade path is reachable in a test without a chain.step_heightsmaps each walked txid to its block height. A step whose height is unknown cannot be placed relative to the mark, so the walk cannot be folded “as of” anything and the verdict degrades — an unplaceable step is not a step that happened after.- Parameters:
ref (str)
binding_source (str)
anchor (MarkAnchor)
walk (MutableChainWalk)
- Return type:
- pyrxd.glyph.mark_anchor_dict(anchor)[source]¶
The display shape of a
MarkAnchor.caveatandheight_is_verifiedare carried, never dropped: the height is one endpoint’s claim and pyrxd has no Radiant header, proof-of-work or merkle check to hold it to. A consumer that shows the number and not the caveat has published the unqualified sentence this module exists to prevent.- Return type:
- pyrxd.glyph.mine_solution(preimage, target, *, algo=DmintAlgo.SHA256D, nonce_width=4, max_attempts=600000000, progress=None, progress_interval_s=0.5)[source]¶
Search for a nonce satisfying the V1/V2 dMint PoW target.
Sequential nonce sweep starting at 0. The nonce is encoded as a little-endian unsigned integer of the requested width (4 bytes for V1, 8 bytes for V2 — matches glyph-miner’s
nonceBytesForContracts).Calls
verify_sha256d_solution()per candidate; that’s the single source of truth for “does this hash satisfy the target.” Drift between the mining check and the verifier check would let pyrxd produce a nonce that passes locally but fails on-chain (or vice versa).- Parameters:
preimage (bytes) – 64-byte preimage from
build_pow_preimage().target (int) – 8-byte 64-bit target (the V1/V2 contract’s
targetstate field).algo (DmintAlgo) – Hash algorithm. Only SHA256D is implemented; BLAKE3 and K12 raise
NotImplementedError.nonce_width (Literal[4, 8]) – 4 for V1, 8 for V2. Keyword-only and
Literal[4, 8]so a stray positional value is a type error rather than a silent V1/V2 confusion.max_attempts (int) – Upper bound on iterations before raising
MaxAttemptsError. SeeDEFAULT_MAX_ATTEMPTS— it is a fail-fast cap in attempts, not a wall-clock budget.progress (Callable[[int, float], None] | None) – Optional
callback(attempts, elapsed_s)invoked roughly everyprogress_interval_swhile grinding. Feed the pair tolive_stats()for an observed rate + remaining-time quantiles. Exceptions raised by the callback propagate to the caller — which is a supported way to impose a deadline on the grind.progress_interval_s (float) – Minimum seconds between callbacks. Checked at a 65536-attempt granularity, so a very slow machine may report less often than requested.
- Raises:
ValidationError –
preimageis not 64 bytes,targetis not positive,nonce_widthis not 4 or 8,max_attemptsis < 1, orprogress_interval_sis not positive.NotImplementedError –
algois BLAKE3 or K12.MaxAttemptsError – No solution found within
max_attemptsiterations. The exception’sattemptsandelapsed_sattributes carry telemetry.
- Return type:
Note
There is no “easy” target for this loop. The verifier requires four leading zero bytes, so the mean is
2**96 / targetand floors at2**33 ≈ 8.6e9attempts even at difficulty 1 — lowering the difficulty cannot bring a run under that. (An earlier version of this docstring claimed a shifted target gave “~1 in 256 expected”; it confused the difficulty multiplier for an attempt count, and no such example completes in milliseconds.) Size a run withestimate_attempts()before starting it, and usebenchmark_sha256d()(orpyrxd glyph dmint-estimate) to turn that into wall clock.Usage:
from pyrxd.glyph.dmint import estimate_attempts, mine_solution est = estimate_attempts(target) # EXACT: mean + quantiles result = mine_solution( preimage, target, nonce_width=4, max_attempts=est.quantile_attempts[-1][1], # e.g. the p99 budget progress=lambda attempts, elapsed: ..., # live rate + ETA )
- pyrxd.glyph.mine_solution_dispatch(preimage, target, *, nonce_width=4, algo=DmintAlgo.SHA256D, miner_argv=None, max_attempts=600000000, timeout_s=600.0, progress=None, progress_interval_s=0.5)[source]¶
Mine a nonce — picks the in-process or subprocess miner from one entrypoint.
Most callers want this helper rather than calling
mine_solution()ormine_solution_external()directly. The two paths share semantics — both return aDmintMineResultwith a nonce that satisfies the target — but have disjoint parameter sets (max_attemptsvstimeout_s, no-argv vs argv). Picking between them was a 30-line wrapper that every demo and operator script ended up rewriting; this function is that wrapper, with the branch in one place.Dispatch rule:
miner_argv is None(default): runmine_solution()in this process. Slow but correct. Use for tests, small examples, and contracts where mining takes < a minute.miner_argv is not None: invokemine_solution_external()with the supplied argv. The external miner (e.g.pyrxd.contrib.miner, a custom binary, orglyph-miner) runs as a subprocess and returns a verified nonce via the JSON-over-stdio protocol. The local re-verification inmine_solution_externalis the load-bearing safety check against a buggy or malicious miner.
- Parameters:
preimage (bytes) – 64-byte preimage from
build_pow_preimage().target (int) – The PoW target.
nonce_width (Literal[4, 8]) – 4 for V1 contracts, 8 for V2.
algo (DmintAlgo) – Hash algorithm. Currently only SHA256D is implemented; BLAKE3 and K12 raise from
mine_solution(). Ignored on the external-miner path (the protocol doesn’t carry an algo field; external miners are assumed SHA256D until the protocol is extended).miner_argv (list[str] | None) –
None→ in-process; otherwise an argv list passed tosubprocess.run()for the external miner. Use[sys.executable, "-m", "pyrxd.contrib.miner"]for the bundled parallel miner.max_attempts (int) – Iteration cap on the in-process path. Ignored on the external-miner path (the external miner caps via
timeout_sinstead).timeout_s (float) – Subprocess timeout on the external-miner path. Ignored in-process (use
max_attemptsthere).progress (Callable[[int, float], None] | None) – Live-progress hook. On the in-process path this is
mine_solution()’s own callback. On the external-miner path it is passed tomine_solution_external(), which streams a miner’s optional stderr progress frames if it emits any (added after 0.13.0) — an external miner that doesn’t know about progress frames simply never triggers the callback, same asprogress=None.progress_interval_s (float) – Minimum seconds between
progresscalls.
- Returns:
DmintMineResultwith the verified nonce.- Raises:
MaxAttemptsError – in-process exhausted
max_attempts, or external miner exceededtimeout_s/ explicitly signalled exhaustion.ValidationError – external miner returned a malformed response or a nonce that fails local verification.
- Return type:
- pyrxd.glyph.mine_solution_external(preimage, target, *, miner_argv, nonce_width=4, timeout_s=600.0, progress=None, progress_interval_s=0.5)[source]¶
Delegate nonce search to an external miner via JSON-over-subprocess.
Spawns
miner_argvas a subprocess, writes one JSON line to its stdin, reads one JSON line from its stdout, and re-verifies the returned nonce locally. The local re-verification is the load-bearing safety check — a wrong nonce from the external process raises rather than getting silently embedded in a transaction.The miner is expected to:
Read one JSON object from stdin:
{"preimage_hex", "target_hex", "nonce_width"}.Search for a valid nonce.
Write one JSON object to stdout — on a hit (exit 0):
{"nonce_hex", "attempts", "elapsed_s"}; on nonce-space exhaustion (exit 2, added in 0.5.1):{"exhausted": true}(pyrxd then raisesMaxAttemptsErrorimmediately rather than waiting for the parent timeout to fire).OPTIONALLY (added after 0.13.0) write zero or more progress lines to stderr while it searches:
{"progress": {"attempts": N, "elapsed_s": F}}, one JSON object per line. Purely additive — a miner that has never heard of this writes nothing extra, and nothing here changes for it.
A bundled reference implementation ships at
pyrxd.contrib.miner(added in 0.5.1) — see Parallel mining and the external-miner protocol for the full protocol spec and operational notes. Invoke it via:miner_argv=[sys.executable, "-m", "pyrxd.contrib.miner"]
Warning
Supply-chain risk: pyrxd does NOT pin or verify the miner binary.
miner_argv[0]is resolved by the OS at exec time, so a malicious binary earlier in$PATHcan intercept calls. The local nonce re-verification (below) defends against the miner returning a wrong nonce, but cannot detect side-channel exfiltration: a malicious miner sees the preimage (which encodes the contract ref + miner binding) and can leak it out-of-band over the network.Mitigations the caller should consider:
Invoke with an absolute path (
["/usr/local/bin/glyph-miner", ...]) rather than a bare name to bypass$PATHresolution.Verify the binary’s checksum against the upstream release before first use.
Run pyrxd in an environment where
$PATHis controlled (e.g. a dedicated user account, sandbox, or container).
For testing and trusted environments the bare-name form is fine.
- Parameters:
preimage (bytes) – 64-byte preimage from
build_pow_preimage().target (int) – The PoW target.
miner_argv (list[str]) – argv passed to
subprocess.run()(e.g.["glyph-miner", "--stdin"]). The first element must be a binary or shell-resolvable name; pyrxd does not pin a specific miner. See the supply-chain warning above.nonce_width (Literal[4, 8]) – 4 for V1, 8 for V2.
timeout_s (float) – Hard timeout. The subprocess is killed and
MaxAttemptsErrorraised on expiry.progress (Callable[[int, float], None] | None) – Optional
callback(attempts, elapsed_s). WhenNone(the default), behavior is byte-for-byte identical to before this parameter existed: a single blockingsubprocess.runcall with stderr fully discarded (subprocess.DEVNULL), same as pyrxd <= 0.13.0. Passing a callback opts into a streaming invocation that reads (rather than discards) stderr, parses any progress frames the miner chooses to emit, and callsprogresswith the most recently observed one roughly everyprogress_interval_s— the same cadence contractmine_solution()documents. An external miner that never emits a progress frame (the common case today) simply meansprogressis never called; the grind still runs to completion or timeout exactly as it would withprogress=None. A raising callback propagates and the subprocess is terminated — the supported way to impose a deadline, mirroringmine_solution()andpyrxd.contrib.miner.parallel.mine.progress_interval_s (float) – Minimum seconds between
progresscalls. Ignored whenprogressisNone.
- Raises:
ValidationError – The miner returned a malformed JSON response, a nonce of wrong width, or a nonce that fails local verification.
MaxAttemptsError – The miner exceeded
timeout_s.FileNotFoundError –
miner_argv[0]is not on PATH.
- Return type:
- pyrxd.glyph.parse_mutable_nft_script(script)[source]¶
Parse a mutable NFT output script, returning (mutable_ref, payload_hash) or None.
- async pyrxd.glyph.resolve_mark_anchor(*, txid, fetch_verbose, source, min_confirmations, tip_height=None)[source]¶
Ask an endpoint where
txidis, and return it qualified.fetch_verboseshould be anElectrumXClient.get_transaction_verbose-shaped call: it binds the echoed txid to the one requested, which is the one thing here that IS checked.- Raises:
ValidationError – if
min_confirmationsis not a positive int. There is no default on purpose — see the module docstring.NetworkError – if the endpoint’s answer is unreadable. Fail closed: an unreadable depth must not read as depth 0 and then as “unconfirmed”, because an unconfirmed mark and a mark whose depth could not be read are different facts and only one of them is benign.
- Parameters:
- Return type:
- pyrxd.glyph.royalty_due(royalty, sale_price)[source]¶
Total photons owed on a sale of
sale_pricephotons.min(max(minimum, floor(sale_price * bps / 10_000)), sale_price).sale_priceis the consideration the seller receives, in photons. There is no such thing as a royalty on a transfer: a gift has no price, and charging basis points of nothing yields nothing.minimumraises the payment toward the sale price; it cannot raise it past.The cap is the whole difference from Photonic’s
calculateRoyalty, and it exists becauseminimumis otherwise an unbounded number chosen by the token’s creator and spent from the funding inputs of whoever moves the token.GlyphRoyaltyonly requiresminimum >= 0. See the module docstring for the use case this deliberately removes.- Raises:
ValidationError –
sale_priceis negative or not anint.- Parameters:
royalty (GlyphRoyalty)
sale_price (int)
- Return type:
- pyrxd.glyph.royalty_output_scripts(payouts)[source]¶
Turn payouts into
(locking_script, photons)pairs, ready for outputs.Plain 25-byte P2PKH locks. They carry no ref, which is the property that makes a royalty safe to bolt onto an FT transfer: Radiant’s conservation rule sums token amounts per ref across the output side, and an output with no ref contributes nothing to any of those sums. A royalty is paid out of the transaction’s RXD side, never out of the token side.
- pyrxd.glyph.royalty_payouts(royalty, sale_price)[source]¶
Resolve
royaltyatsale_priceinto concrete, addressed payouts.sum(p.photons for p in result) == royalty_due(royalty, sale_price)exactly, unless the total is 0 (in which case the result is empty).With no
splitsthis is a single payout toroyalty.address. Withsplitsthe total is dividedfloor(total * split_bps / bps)per recipient and the residue — flooring loss plus any bps the splits do not cover — goes toroyalty.address. Recipients that round to zero photons are dropped.This is also where royalty addresses are actually validated.
GlyphRoyaltyonly checks that the address string is non-empty, so a typo survives minting, sits in the signed CBOR, and would otherwise surface as a burned output at payment time.- Raises:
ValidationError – any recipient address fails to decode, or
sale_priceis invalid.- Parameters:
royalty (GlyphRoyalty)
sale_price (int)
- Return type:
tuple[RoyaltyPayout, …]
- pyrxd.glyph.sign_metadata(metadata, private_key, algo='ecdsa-secp256k1')[source]¶
Return a new GlyphMetadata with creator.sig populated.
The private key’s compressed public key is embedded as creator.pubkey. The signing protocol is:
Build canonical CBOR with sig=”” and the pubkey.
commit_hash = SHA256d(cbor)
message = SHA256(“glyph-v2-creator:” || commit_hash)
sig = ECDSA(private_key, message) [low-s DER, no double-hash]
- Parameters:
metadata (GlyphMetadata) – GlyphMetadata to sign. Any existing creator field is replaced.
private_key (PrivateKey) – pyrxd PrivateKey — the token deployer’s key.
algo (str) – Signing algorithm identifier (default: “ecdsa-secp256k1”).
- Returns:
A frozen copy of metadata with creator.sig set.
- Return type:
- pyrxd.glyph.split_qualified_name(qualified)[source]¶
Split
"alice.rxd"into("alice", "rxd").Names with no domain (e.g.
"alice") default to domain"rxd"— matching Photonic’s behavior. Names with multiple dots use the LAST dot as the domain separator (so"foo.bar.rxd"is("foo.bar", "rxd")).
- pyrxd.glyph.target_to_difficulty(target, algo=DmintAlgo.SHA256D)[source]¶
Convert PoW target to difficulty (approximate).
- pyrxd.glyph.verify_authority_claim(authority_ref, verdicts)[source]¶
Does the item’s
byclaim on authority_ref stand up?Deliberately takes VERDICTS rather than metadata. At
becf41aPhotonic’sverifyAuthorityChainmatched thebyfield against a candidate authority’s ref and reported success on a string match — so a forger who wrote a real issuer’s ref into their ownbypassed it (reported as M26). The argument for taking verdicts does not depend on that defect:byis an operator assertion; onlyverify_relationship_claims()can say whether anything authorised it.Pass the verdicts that function returned for the item’s reveal transaction. An UNBACKED author claim is reported as unproven, not as an issuer.
- pyrxd.glyph.verify_authority_gate(genesis_output_script, authority_ref, *, item_ref)[source]¶
Was this item minted under authority_ref? The consensus-backed question.
genesis_output_script must be the item’s output script as it was created — from the reveal transaction that minted it, not from wherever the item lives now. Measured on a node (
tests/test_authority_regtest_e2e.py): a holder can transfer a gated item to a plain NFT script unilaterally, keeping the ref and losing the gate. Ask this of a current UTXO and a holder can make the answer whatever they like.A positive verdict means Radiant refused to create that output unless the minting transaction’s input ref set contained authority_ref — i.e. the minter held the authority token. It does NOT mean the authority is still valid, unexpired, or unrevoked; those are metadata questions.
- pyrxd.glyph.verify_burn(output_scripts, token_ref, spent_output_scripts)[source]¶
Check a burn claim against what the transaction actually did.
- Parameters:
output_scripts (list[bytes]) – every output script of the burning transaction.
token_ref (GlyphRef) – the token the caller is asking about.
spent_output_scripts (list[bytes]) –
the locking scripts of the outputs this transaction SPENT. They live in earlier transactions, so the caller fetches them.
Required, deliberately. It was optional, and omitting it returned
ok=Falsefor a genuine burn — a function calledverify_burnanswering False about a real burn is the most surprising thing an API can do. Absence from the outputs alone is a condition every unrelated transaction on the chain satisfies, so there is no useful verdict to give without this. Requiring it means the weak answer cannot arise: either you have the evidence, or you cannot ask. Pass[]only if you genuinely mean “this transaction spent nothing relevant”, which is a refusal.
- Return type:
BurnVerdict
What an ``ok`` verdict binds, stated exactly. One output parses as a Glyph burn proof naming token_ref; no script in
output_scriptspushes that ref under0xd0/0xd8; and some script inspent_output_scriptsdoes.What it does NOT bind:
Any transaction. No txid appears anywhere here, and the two lists are never cross-checked against each other — “these outputs and these spent outputs belong to one transaction” is the caller’s assertion, not a finding.
The supply, for a FUNGIBLE token. This used to say “it means the token is gone”, which is false for an FT: one spent FT UTXO with no FT output satisfies every check above while the rest of the supply sits in other UTXOs. It means the units in the spent output are gone.
``proof.amount`` or ``proof.action``. Both are operator-authored CBOR that nothing verifies, and they ride out attached to an
okverdict. Anyone can write a burn proof about any token; metering supply fromokplusamounttakes an attacker-chosen number as consensus-backed.
It never means “the owner intended this” either.
- pyrxd.glyph.verify_creator_signature(metadata)[source]¶
Check that
creator.pubkeysigned this metadata.WHAT A
TrueESTABLISHES, EXACTLY: the key named in this blob signed this blob. Nothing more.creator.pubkeyis a field of the same metadata being verified — nothing here binds it to the minting key, to the commit outpoint, or to any identity known in advance.SO IT DOES NOT ESTABLISH AUTHORSHIP, and the failure is not subtle. Anyone can take a token’s metadata verbatim, re-sign it with their own key, and mint a copy whose
verify_creator_signaturereturns(True, "")— indistinguishable from the original. Demonstrated intests/test_creator_signature_scope.py. A marketplace building a “verified creator” badge on this boolean would badge the counterfeit.TO GET AUTHORSHIP you need a key fixed IN ADVANCE to compare the recovered one against. That is the standard this repo already applies one module over, in
pyrxd.script.hashmark.verify_attestation(): “Without a value fixed in advance to compare against, recovery is circular and proves nothing: an attacker would simply write whatever hash their chosen signature recovers to.” HashMark commits the signer hash160 twice and requires both to match; this has one copy and compares it to itself.The check is still worth having — it detects a metadata blob altered after signing, which is a real thing to detect. It is the INFERENCE from
Truethat has to stay narrow.- Returns:
(True, “”) if the named key signed this metadata; (False, reason) otherwise. A non-empty reason on
Trueflags a lossy decode — see_cbor_for_verifying().- Parameters:
metadata (GlyphMetadata)
- Return type:
- pyrxd.glyph.verify_sha256d_solution(preimage, nonce, target, *, nonce_width=8)[source]¶
Verify a SHA256d PoW solution.
Valid if: hash[0..4] == 0x00000000 AND int.from_bytes(hash[4..12], ‘big’) < target
target is clamped to MAX_SHA256D_TARGET before comparison — a caller-supplied target above the maximum would make the check trivially pass for any hash that starts with four zero bytes.
- async pyrxd.glyph.walk_mutable_chain(*, mint_txid, candidates, fetch_tx, is_unspent=None, candidate_source='', tip_source='', max_steps=256)[source]¶
Follow a mutable glyph from
mint_txidalong its own spend chain.candidatesis a DISCOVERY hint - typically an index’s history for the token. Membership is not taken from it: a candidate joins the chain only by spending the previous step’s mutable output and producing one carrying the same ref.fetch_txmust return a parsed transaction with.inputs(source_txid,source_output_index,unlocking_script) and.outputs(satoshis,locking_script). It is the caller’s job to bind the returned transaction to the txid requested; a server that answers with a different transaction is out of scope here.is_unspent(txid, vout)proves the tip. Omitting it is not a shortcut: the walk then reportscomplete=False, because an unproved tip cannot be distinguished from a truncated history.- Raises:
ValidationError – only for a CONTRADICTION - a step whose mutable output carries a different ref. Absence degrades; contradiction raises. That split follows
glyph/dmint/chain.py’s S2 verifier, where a server disagreeing with itself is not a “no result”.- Parameters:
- Return type:
- pyrxd.glyph.wave_attrs_from_metadata(metadata)[source]¶
Convenience wrapper: extract
WaveAttrsfrom a parsedGlyphMetadata(typically fromGlyphInspector.extract_reveal_metadata()).Returns
Nonefor non-WAVE metadata or legacy-shape WAVE withoutattrs.name(which RXinDexer cannot index).- Parameters:
metadata (GlyphMetadata)
- Return type:
WaveAttrs | None