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.AnchorBindingError[source]¶
Bases:
NetworkErrorThe endpoint answered, but no header in the search window binds the height to its block.
Not “unreachable”: the endpoint answered the depth and the block hash. A caller reporting this should suggest re-running or asking another server, rather than “check that the server is reachable” — and should say WHICH of three things happened, from the attributes rather than by parsing the message:
disagrees— every height in the window was served and none hashes to the block the node named: the endpoint’s index and its node disagree (or it is serving inconsistent data).unservednon-empty — some (or all) of the headers could not be read, so nothing established a disagreement: the matching header may simply be the one that never arrived. “Its index and its node disagree” would be a false sentence here, and was one (0.25.0 review, round 2: an honest chain whose request for the mark’s own header went unanswered).both empty — the endpoint named no block to bind to at all.
servedandunservedare the heights, in the order they were tried.
- exception pyrxd.glyph.BroadcastEchoMismatch[source]¶
Bases:
RxdSdkErrorThe server’s broadcast reply named a txid other than the transaction we sent.
Deliberately NOT a
ValidationError, and NOT aNetworkError. Those are what a caller retries on — aValidationErroris raised before anything is sent, aNetworkErrorreads as “the server could not be reached” — whereas this one can only happen after the broadcast, when the transaction may well have relayed. A caller withexcept NetworkError: retrythat rebuilds would send a SECOND transaction, paying twice if the first one went through.Carries
local_txid(hashed from the bytes that were sent) so the caller can check the chain for what was actually sent, andechoed(the server’s claim, unverified).Raised by
pyrxd.network.electrumx.verified_broadcast_txid(), the one echo check; re-exported aspyrxd.glyph.client.BroadcastEchoMismatchandpyrxd.BroadcastEchoMismatch.
- class pyrxd.glyph.ChainStep[source]¶
Bases:
objectOne transaction in the singleton’s own history.
- __init__(txid, mut_vout, kind, attrs=<factory>, reason='', envelope_cbor=b'')¶
- envelope_cbor: bytes = b''¶
The committed envelope CBOR, VERBATIM — the bytes whose
sha256dthis step’s mutable output commits to. Empty for a step whose record could not be bound or read.Carried because
attrsis not the whole record. A WAVE mint names itself inattrs.nameORapp.data.name(RXinDexer reads both), and the second is outsideattrs; a consumer that needs to know what the glyph’s OWN payload claims — HashMark §7.6 form 2 checking the name an index bound to it — has to read these bytes, which are already txid-bound (fetched through a hashing fetch) and hash-bound (the covenant commits to them).compare=False: two steps are the same step whatever bytes rode along.
- 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, *, registration_fee_output)¶
- registration_fee_output: WaveRegistrationFee | None¶
The WAVE registration fee this reveal must pay, or
None. AsRevealScripts.registration_fee_output: after both token outputs, before change.
- 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, *, registration_fee_output)¶
- Parameters:
ref (GlyphRef)
locking_script (bytes)
scriptsig_suffix (bytes)
child_ref (GlyphRef | None)
registration_fee_output (WaveRegistrationFee | None)
- Return type:
None
- registration_fee_output: WaveRegistrationFee | None¶
The WAVE registration fee this reveal must pay, or
None. AsRevealScripts.registration_fee_output: after the token output, before change.
- class pyrxd.glyph.DaaBytecodeVersion[source]¶
Bases:
IntEnumWhich GENERATION of ASERT/LWMA retarget bytecode a deployed contract carries.
This is orthogonal to the dMint contract format (V1 vs V2 state layout): every member here is a V2-format contract. The retarget formula baked into Part B changed upstream on 2026-06-19 (ASERT, Photonic
ed53cd41) and 2026-06-20 (LWMA, Photonicc90e6506); pyrxd did not follow until 2026-09-16, so contracts deployed by pyrxd in between carry the older formula and MUST keep being mined under it — a covenant’s bytecode is immutable, and a miner that recomputes the next target with the wrong formula produces a state the covenant rejects.V2— the current canonical fractional fixed-point retarget (_build_asert_daa_v2/_build_linear_daa_v2). Every new deploy.LEGACY— the integer power-of-2 ASERT stepper (_build_asert_daa_legacy) or the unity-gain LWMA WITH theOP_0 OP_MAXtimeDelta floor (_build_linear_daa_legacy); what pyrxd emitted from 2026-06-17 to 2026-09-15.LEGACY_LWMA_PREFLOOR— LWMA only: the unity-gain retarget WITHOUT the timeDelta floor (_build_linear_daa_legacy_prefloor), as pyrxd emitted on 2026-06-16 — the bytecode of the mainnet LWMA deploydea3beb9….
Detected from a contract’s code section by
pyrxd.glyph.dmint.builders.detect_daa_bytecode(); a contract matching no known template is REPORTED (UnrecognizedDaaBytecodeError), never guessed.- __new__(value)¶
- LEGACY_LWMA_PREFLOOR = 0¶
- LEGACY = 1¶
- V2 = 2¶
- 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’s
DmintPayloadtype (packages/lib/src/types.tsatbecf41a):daa?: { mode, targetBlockTime, halfLife?, asymptote?, // ASERT windowSize?, // LWMA epochLength?, maxAdjustment?, // EPOCH schedule?: { height, difficulty }[] } // SCHEDULEEvery optional key is emitted ONLY when set (non-zero / non-empty), so a payload that does not use one is byte-identical to what pyrxd emitted before the key existed (2026-09-16:
asymptote,epochLength,maxAdjustment,schedulewere added; FIXED/ASERT/LWMA payloads that do not set them are unchanged).scheduleentries are(height, difficulty)— difficulty, not target, is what the payload type declares.max_adjustmentis stored as the RAW payload number and is informational only: Photonic’s builder reads a payload value of 1..4 as the log2 shift count and 8/16 as multipliers (script.tsmaxAdjustmentToLog2; its Mint UI passes the same number to the payload and the builder), while pyrxd’sdeploy-dmint --max-adjustmentis a multiplier baked asDmintDeployParams.max_adjustment_log2. So the payload value alone does not say which clamp a contract bakes — only the contract bytecode does. Validation accepts exactly the values Photonic’s builder accepts (EPOCH_MAX_ADJUSTMENT_PAYLOAD_VALUES); pyrxd does not reinterpret them.- __init__(algo, num_contracts, max_height, reward, premine, diff, daa_mode=DaaMode.FIXED, target_block_time=60, half_life=0, window_size=0, asymptote=0, epoch_length=0, max_adjustment=0, schedule=())¶
- 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=240, height=0, last_time=0, epoch_length=2016, max_adjustment_log2=2, schedule=())¶
- Parameters:
- Return type:
None
- property initial_target: int¶
The deploy target for
difficulty—target_for_difficulty(), for everyalgo.BLAKE3 and K12 used to get
MAX_V2_TARGET_256 // difficultyhere: a target wider than the 8 bytes Part B2 can read, so the contract could never be minted.algoselects the hash opcode (and the algoId the state records); it does not change the target formula.
- 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). On the final mint no output carries a state: this is the spent state with onlyheightadvanced tomax_height(sois_exhaustedis True).contract_script – The script of output 0: the recreated contract (state + separator + code), or on the final mint the burn
d8 <contractRef> 6athat replaces it.reward_script – The FT-wrapped reward output script (P2PKH, then
bd d0 <tokenRef>and the 12-byte FT code), paid to the miner.fee – Transaction fee in photons.
is_final_mint – True when this mint takes the contract to
max_height: output 0 is the burn, not a recreated contract, and no further mint of this contract is possible.
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, is_final_mint=False)¶
- 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:
- property next_mint_is_final: bool¶
True when the next mint takes this contract to
max_height.That mint recreates no contract: the covenant’s final branch requires the burn
d8 <contractRef> 6ain its place (seebuild_dmint_contract_burn_script()), andbuild_dmint_mint_tx()builds exactly that.
- 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.
max_height – Maximum mints per contract.
target – The PoW target (
MAX_SHA256D_TARGET // difficulty). The codescript is rebuilt with today’s builder, which pushes it minimally; a contract pyrxd deployed before 2026-09-23 at difficulty 256 or more carries a non-minimal 8-byte push instead, so this fast path does not find it (it can never be minted anyway).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,
[1, 2**31]. A V1 contract’s height is a 4-byte field that every mint but the last rewrites asNUM2BIN(height + 1, 4)(epilogue54 78 54 80 7e), which cannot encode2**31, so with a largermax_heightthe contract stops at height2**31 - 1with mints left (MAX_V1_MAX_HEIGHT;check_dmint_v1_bounds()has this and the next two bounds). Mainnet has such V1 deploys ($BRO: 696,969,000,000); pyrxd mints them up to that height but does not deploy one.reward_photons – Photons paid per successful mint,
[1, RADIANT_MAX_PHOTONS].difficulty – Initial PoW difficulty (1 = easiest; at most
MAX_SHA256D_TARGET). The target isMAX_SHA256D_TARGET // difficulty(difficulty_to_target()), pushed as a minimal script number.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 (
[1, 2**63 - 1]: the covenant reads it as a script number). The upper bounds on this and the other numeric parameters, and the reason for each, are inpyrxd.glyph.dmint.types.check_v2_numeric_bounds().reward_photons – Photons paid per successful mint (
[1, RADIANT_MAX_PHOTONS]).difficulty – Initial PoW difficulty (1 = easiest; at most
MAX_SHA256D_TARGET). The target isMAX_SHA256D_TARGET // difficultyfor everyalgo.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). BLAKE3 and K12 deploy with the same 8-byte target formula as SHA256d, as Photonic does. pyrxd can deploy them but cannot mine them: every miner it runs or speaks to (including the external-miner protocol behind
claim-dmint --miner-cmd) grinds and verifies SHA256d only, andclaim-dmintrefuses a BLAKE3/K12 contract before any grind. Minting one needs a miner for that hash, which pyrxd does not ship.daa_mode – Any
DaaMode(FIXED, ASERT, LWMA, EPOCH, SCHEDULE).target_time – Echoed into the state (DAA-only; vestigial for FIXED). At most
MAX_V2_TARGET_TIME(0xFFFFFFFFs) for ASERT, LWMA and EPOCH, whose retarget reads it; FIXED and SCHEDULE never read it as a number and take up to2**63 - 1.half_life – Baked into the ASERT-v2 bytecode (ASERT only; vestigial otherwise). Defaults to the canonical Photonic
DEFAULT_ASERT_HALFLIFE(240 s) so an omitted value deploys what a Photonic miner assumes; before 2026-09-16 the default was 3600.last_time – Unix timestamp written into the deployed state’s
lastTimeslot — the baseline the FIRST mint’s retarget measures against.None(the default) stamps the build time, which is what Photonic’sdMintScriptcall site passes (Math.floor(Date.now() / 1000)). For ASERT and LWMA this value is read as a script number on the first mint, so a non-minimal 4-byte encoding (anything below2**23, including the old implicit 0) makes the contract unmineable from birth — such a value is refused here rather than deployed. A value above0x7FFFFFFFis refused too: bit 31 is the script-number sign, so the covenant would read it as negative (seerequire_mineable_last_time()).
- __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=240, last_time=None, 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)
last_time (int | None)
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().
- Parameters:
last_time – The RESOLVED deploy timestamp (
params.last_time, or the build time when that wasNone). Resolved once inprepare_dmint_deployand carried here so the placeholder scripts and the real reveal scripts agree.
- __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, last_time, 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)
last_time (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, *, pay_registration_fee=True, registration_treasury=None)[source]¶
Prepare scripts for minting an item gated on an issuer’s authority.
Build the reveal with two inputs — the commit outpoint and the authority token’s UTXO — and two token outputs:
output
script
0item_script1authority_script(plus change). The authority output is re-emitted VERBATIM, so it neither moves nor changes hands — and, more to the point, is not destroyed. Spending a singleton without re-creating it burns it irrecoverably.
authority_script is the authority UTXO’s CURRENT locking script, not a PKH, for two reasons. Rebuilding it from a PKH with
build_nft_locking_scriptSTRIPS anything the authority itself carried — an authority that is itself authority-gated came back un-gated, silently, in the transaction that was supposed to leave it untouched. And aHex20here sat next to owner_pkh, so transposing the two irreversibly gifted the issuer’s authority to the mint recipient in a transaction consensus accepts. A script cannot be confused with a PKH.Consensus enforces the gate:
OP_REQUIREINPUTREFin the item’s script is subset-checked against this transaction’s inputs, so the mint fails outright without the authority. Measured, along with what the gate does NOT bind, intests/test_authority_regtest_e2e.py— readbuild_authority_gated_nft_script()before treating “gated” as a durable property of the minted item.If
cbor_bytesis marked WAVE and registers a name, the result carries its registration fee (pay_registration_fee,registration_treasury: asprepare_mutable_reveal()), paid after both token outputs.- Raises:
ValidationError – the envelope is unparseable or does not include
GlyphProtocol.NFT.- Parameters:
- Return type:
AuthorityGatedRevealScripts
- static prepare_burn_proof(token_ref, *, amount=None, burn_reason=None)[source]¶
The
OP_RETURNoutput that records a deliberate burn.Add it to the transaction that spends the token, with value 0, and do NOT re-create the token in any output. See
build_burn_proof_script()for what the proof does and does not establish — it is an operator claim, andverify_burn()is careful about which parts of it a reader may rely on.
- prepare_commit(params)[source]¶
Prepare the commit transaction parameters.
Returns the commit locking script + CBOR bytes + estimated fee. Caller must build, sign, and broadcast the actual transaction.
The commit script’s
OP_REFTYPE_OUTPUTcheck is derived frommetadata.protocol: NFT (2in protocol) produces anOP_2/SINGLETON-expecting commit; any other protocol mix (FT, dMint FT, data, etc.) produces anOP_1/NORMAL-expecting commit. This means the caller does not hand-pick refType — the metadata drives it. Prior versions forced every commit to NFT shape; seebuild_commit_locking_scriptfor the fix note.A WAVE claim outside the rule pyrxd writes claims by is refused HERE, before the commit exists: once a commit is broadcast it can only be spent by revealing exactly the CBOR it commits to, so a refusal at reveal time would strand its value. There is no override on this path (see
refuse_unregistrable_wave_claim()).Nothing for a WAVE claim’s registration fee goes in the commit. The reveal pays it, from a second, plain wallet input (Photonic’s shape; see
prepare_wave_reveal()), so the fee stays in the wallet until the reveal that registers the name spends it.wave_registration_fee_for(result.cbor_bytes)gives the amount before anything is broadcast.- Parameters:
params (CommitParams)
- Return type:
CommitResult
- prepare_container_child_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, container_ref, *, container_script, pay_registration_fee=True, registration_treasury=None)[source]¶
Prepare scripts for revealing a token into a container.
The child is an ordinary NFT. What makes its membership checkable is the transaction shape: the reveal spends the container’s own NFT UTXO and re-creates it unchanged, so the container ref appears among the reveal’s output-script refs. Photonic’s indexer only honours an
inentry that it can find there (filterRels,packages/app/src/electrum/worker/NFT.ts) — a claimedinref with no matching output ref is dropped, which is what stops anyone declaring their token part of someone else’s collection.Build the reveal with two inputs — the commit outpoint and the container NFT UTXO — and two token outputs:
output
script
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.If
cbor_bytesis marked WAVE and registers a name, the result carries its registration fee (pay_registration_fee,registration_treasury: asprepare_mutable_reveal()), paid after both token outputs.- Raises:
ValidationError – the envelope is unparseable, does not include
GlyphProtocol.NFT, or itsinlist does not containcontainer_ref.- Parameters:
- Return type:
- prepare_container_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, child_ref=None, *, pay_registration_fee=True, registration_treasury=None)[source]¶
Prepare scripts for a CONTAINER (collection) reveal.
A container’s locking script is the plain 63-byte NFT singleton of
build_nft_locking_script(). Container-ness is carried by the7marker in the envelope’spfield, exactly as in Photonic Wallet (packages/lib/src/script.tshas onenftScriptand no container variant). That is what makes a container a first-class token: every NFT classifier, the scanner, andbuild_nft_transfer_tx()handle it unchanged.Membership points child → parent and lives in the child’s envelope, in the
infield (container_refs). Useprepare_container_child_reveal()to mint a member.Protocol field must include
GlyphProtocol.CONTAINER(7).- Parameters:
- Return type:
The
child_refprefix (removed in 0.15.0)¶pyrxd 0.9.0–0.14.0 prefixed the NFT body with
OP_PUSHINPUTREF <child_ref>whenchild_refwas given. That 100-byte script was never a working token, and both defects were confirmed against a Radiant Core v3.1.1 regtest node (tests/test_container_regtest_e2e.py):The output could not be spent.
OP_PUSHINPUTREFleaves the ref on the stack and nothing drops it, so the P2PKH tail hashed the ref andOP_EQUALVERIFYfailed for every possible scriptSig. Any photons placed on it were unrecoverable.Creating one destroyed the child NFT.
OP_PUSHINPUTREFSINGLETONalso registers its ref as a disallowed sibling (CScript::GetPushRefs), so the child could not be re-created alongside the container — and a singleton consumed into a0xd0push never re-entersinputSingletonRefSet, so it can never be minted again.
Even with the missing
OP_DROPrepaired, a script-level link to a live NFT is impossible on Radiant for the second reason, and a repaired link would still be droppable by the holder at any transfer. Membership is therefore metadata, here as in Photonic.If
cbor_bytesis also marked WAVE and registers a name, the result carries its registration fee (pay_registration_fee,registration_treasury: asprepare_mutable_reveal()).
- prepare_dat_commit(params)[source]¶
Prepare a DAT (data-storage) commit — a glyph whose commit demands no token.
Same two-transaction shape as
prepare_commit(), but the commit carries noOP_REFTYPE_OUTPUTobligation: the reveal need create no NFT or FT (nor is it prevented). What survives is the reveal’s payload.Protocol must include
GlyphProtocol.DAT(3). Build the reveal withprepare_dat_reveal()— a DAT commit pops an extra"dat"marker and the ordinary reveal scriptSig is one push short of satisfying it.- Parameters:
params (CommitParams)
- Return type:
CommitResult
- prepare_dat_reveal(cbor_bytes, *, delegate_ref=None)[source]¶
Prepare a DAT reveal’s scriptSig suffix.
There is no
locking_script: a DAT commit demands no token, so the caller’s outputs are whatever they want to keep the value on (ordinary P2PKH change).RevealScripts.locking_scriptis returned empty to say so rather than handing back a token script that would be wrong.
- prepare_delegate_setup(owner_pkh, authorised_refs, *, parent_scripts, base_ref=None, token_count=0)[source]¶
Prepare the one-time delegate setup that authorises
in/byclaims.This is the alternative to
prepare_container_child_reveal()for a minting service, and the only write path pyrxd has forbyat all. Where the container-child reveal makes every mint spend and re-create the parent — permanent custody in the minting wallet, and one serialised UTXO every mint contends on — a delegate spends the parents once.Two transactions, because the second needs the first’s outpoint:
Call with authorised_refs only. Spend the container and/or author tokens, with outputs =
base_scriptplus every script in :attr:`~DelegateSetupScripts.parent_scripts`, which re-create the parents unchanged. Consensus refuses the base output unless those refs really were among the inputs (OP_REQUIREINPUTREFis subset-checked), which is what makes every later claim authorised rather than merely asserted.Omitting the parent outputs BURNS the container and author tokens.
OP_REQUIREINPUTREFrequires a ref as an input and does not carry it forward, so a base transaction that does not re-create the parents destroys them — permanently, since a consumed singleton can never be re-minted. Only once the parents are back in outputs is it true that they can go to cold storage and never be spent again.Call again with base_ref (that output’s outpoint) and a token_count. Spend the base, paying to each of
token_scripts. Pre-mint as many as you expect to need — N tokens serve N concurrent mints with no lock on a shared UTXO, which is the operational point.
Warning
A delegate token is not one mint. It is an unlimited mint pass. An earlier version of this text said “each token authorises one mint”; that was wrong, and MEASURED wrong on a node (
test_ONE_delegate_token_can_mint_MANY_more). The 56-byte prefix is a covenant on the REVEAL. The commit that spends a delegate token is an ordinary transaction under no covenant, and the token’s script isOP_PUSHINPUTREF <base>— so spending one puts the base ref in the input ref set, and consensus lets one input ref back arbitrarily many output copies. One token was spent into three on regtest.So a token handed to a third party lets them mint into the collection without limit until the base is retired. Treat the tokens as bearer credentials for the collection, not as counted vouchers, and keep them in the minting service rather than distributing them.
Then each mint passes
base_refasCommitParams.delegate_ref, spends one token in the commit, and emitsRevealScripts.delegate_burn_scriptin the reveal.What this does NOT prove: that the parent’s owner approved this particular mint. It proves the mint held a token from a base that held the parents. Anyone holding a delegate token can make the claim — that is the mechanism working as designed, and why
RelationshipBasisreports DELEGATED separately from DIRECT rather than flattening the two.- Parameters:
parent_scripts (Sequence[bytes]) –
the parents’ OWN current locking scripts, in the same order as authorised_refs, re-created verbatim.
This used to be a single
parent_owner_pkhand the outputs were rebuilt withbuild_nft_locking_script(). That is the exact hazardprepare_authority_gated_reveal()documents forty lines from here and refuses: rebuilding from a PKH STRIPS whatever the parent itself carried. A container or author that is itself authority-gated (101 bytes), mutable, or held by a soulbound covenant came back as a plain 63-byte NFT — ref preserved, covenant gone — in the one transaction whose stated purpose is to leave the parents untouched before they return to cold storage.Taking the scripts verbatim also settles the question the old parameter existed for. A parent keeps paying whoever it already paid, so a hot minting service cannot silently move a cold-held singleton to the hot key, and two parents held by two different keys are not consolidated. Both were argued for at length in prose; now neither is expressible.
Each script is cross-checked with
script_carries_ref()against the ref it is paired with, so a mismatched or reordered list is refused rather than silently re-creating the wrong parent.owner_pkh (Hex20)
base_ref (GlyphRef | None)
token_count (int)
- Raises:
ValidationError – authorised_refs is empty, or token_count is given without base_ref (or vice versa with no tokens to build).
- Return type:
DelegateSetupScripts
- prepare_dmint_deploy(params, *, allow_v2_deploy=True)[source]¶
Prepare a dMint token deploy.
Dispatches on the type of
params:DmintV1DeployParams→ returnsDmintV1DeployResult. V1 is the only format on Radiant mainnet today (see GLYPH at a443d9df…878b). Two-tx deploy: commit + reveal (the reveal directly createsparams.num_contractsparallel contract UTXOs).DmintV2DeployParams→ returnsDmintV2DeployResult. V2 is consensus-proven on regtest + mainnet (#219) and now deploys by default (allow_v2_deploy=True). A softUserWarningis emitted if the caller explicitly passesallow_v2_deploy=Falseso the historical opt-out path stays observable without blocking.
- Parameters:
params (DmintV1DeployParams | DmintV2DeployParams) – Either
DmintV1DeployParams(V1 deploy) orDmintV2DeployParams(V2 deploy). The deprecatedDmintFullDeployParamsis accepted (it’s a subclass ofDmintV2DeployParams) but emits aDeprecationWarningat construction time.allow_v2_deploy (bool) – Retained for backward-compatibility; defaults to
True(V2 deploys by default). Ignored for V1.
- Returns:
V1 or V2 result, matching the param type via
@overload.- Raises:
ValidationError – Various per-version invariants — see
_prepare_dmint_v1_deploy()and the V2 implementation below for specifics.- Return type:
- prepare_ft_deploy_reveal(commit_txid, commit_vout, commit_value, cbor_bytes, premine_pkh, premine_amount)[source]¶
Prepare reveal scripts + premine amount for an FT deploy.
Thin convenience wrapper around
prepare_reveal()for the FT-deploy-with-premine flow: the reveal produces one FT output carrying the full issued supply topremine_pkh. The permanent token ref is the commit outpoint (commit_txid:commit_vout), which this method embeds into the reveal’s locking script — not the reveal’s own outpoint.Caller still constructs the actual transaction. The returned
premine_amountis whatvout[0].valuemust be on the reveal tx — typically the full supply for a premine-only deploy (no covenant UTXO). Radiant FT convention: 1 photon = 1 FT unit, sopremine_amountis the supply in whole units.No dMint-specific logic here. The
cbor_bytesalready encode whatever protocol markers the caller chose — dMint FT ([1,4]), plain FT ([1]), or any other combination — viaGlyphMetadata. pyrxd treats the protocol markers as caller-owned; classification happens at the indexer layer.
- prepare_mutable_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, *, allow_unregistrable_wave=False, pay_registration_fee=True, registration_treasury=None)[source]¶
Prepare scripts for a MUT (mutable NFT) reveal.
A payload marked WAVE is held to the WAVE claim rule (
refuse_unregistrable_wave_claim()) unlessallow_unregistrable_wave=True, which exists only to reveal a commit pyrxd ≤0.24.0 already broadcast; whether the claim so revealed registers is the indexer’s rule (seeprepare_wave_reveal()), and when it does it owes the fee. How to rebuild that commit’s exact bytes is inprepare_wave_reveal().A payload that registers a WAVE name owes the registration fee:
registration_fee_outputon the result, which the caller puts at vout 2 and funds from a plain wallet input (seeprepare_wave_reveal()).pay_registration_fee=Falsedeclines it andregistration_treasuryredirects it; seewave_registration_fee_for().Returns the two output locking scripts the caller must place in the reveal tx:
nft_script: 63-byte NFT singleton (token the owner holds), carryingref = commit_txid:commit_voutcontract_script: 174-byte mutable contract UTXO (holds state), carryingmutable_ref = commit_txid:(commit_vout + 1)
The reveal scriptSig suffix is also returned; the caller prepends
<sig> <pubkey>to form the full scriptSig.Protocol field in
cbor_bytesmust includeGlyphProtocol.MUT(5). UseGlyphMetadata(protocol=[GlyphProtocol.NFT, GlyphProtocol.MUT]).The reveal needs TWO inputs¶
input
outpoint
0commit_txid:commit_vout— the commit (reveal scriptSig)1commit_txid:(commit_vout + 1)— a plain seed outputThe commit transaction must therefore carry a second, ordinary output at ``commit_vout + 1`` (Photonic funds it with 1 photon; Radiant has no dust rule). Spending it is what puts
mutable_refinto the transaction’s input singleton-ref set, which is the only thing that lets an output push it.Why the two refs cannot be the same one¶
pyrxd 0.9.0-0.15.0 used
reffor both scripts. A reveal built as documented above was rejected by consensus every time — confirmed against a Radiant Core v3.1.1 regtest node (tests/test_mut_wave_regtest_e2e.py), reject reasonbad-txns-inputs-outputs-invalid-transaction-reference-operations. Two independent chain rules forbid it:OP_PUSHINPUTREFSINGLETONfiles its ref intofoundDisallowedSiblingRefsas well as the push-ref set (CScript::GetPushRefs), andvalidateTransactionReferenceOperationsrejects a transaction where two outputs claim the same one. Both the NFT script and the mutable contract lead with0xd8, so they can never carry the same ref.The contract’s own body derives the token ref from its ref by subtracting one from the vout (
OP_DUP 20 OP_SPLIT OP_BIN2NUM OP_1SUB OP_4 OP_NUM2BIN OP_CAT).mutable_ref.vout == ref.vout + 1is therefore not a convention — the covenant computes it. With equal refs the contract would look forcommit_vout - 1and match nothing, so even a repaired sibling rule would leave the contract unspendable.
This matches Photonic Wallet (
packages/lib/src/mint.ts:Outpoint.fromUTXO(mint.utxo.txid, mint.utxo.vout + 1)).Note
Spending the contract later (the
mod/sloperations ofbuild_mutable_scriptsig()) additionally requires the token output to be re-created in Photonic’snftAuthScriptshape — anOP_REQUIREINPUTREF <mutable_ref> <sha256(contract scriptSig)> OP_2DROPstate prefix ahead of the singleton. pyrxd has no builder for that shape yet; the working transaction is spelled out intests/test_mut_wave_regtest_e2e.py.
- prepare_reveal(params)[source]¶
Prepare the reveal transaction scripts.
Returns locking script + scriptSig suffix. Caller must build, sign, and broadcast the actual transaction.
A payload that registers a WAVE name also gets
RevealScripts.registration_fee_output, which the reveal must pay unlessparams.pay_registration_feeisFalse.pyrxd glyph mint-nftreaches here with a WAVE metadata file, and puts that output at vout 1, after the NFT.- Parameters:
params (RevealParams)
- Return type:
RevealScripts
- prepare_wave_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, name, *, allow_unregistrable_wave=False, pay_registration_fee=True, registration_treasury=None)[source]¶
Prepare scripts for a WAVE (on-chain naming) reveal.
THE RESULT CARRIES THE REGISTRATION FEE, AND THE CALLER PAYS IT.
registration_fee_outputis aWaveRegistrationFee: an output ofwave_registration_price(name)photons — 100 RXD for a label of 3 characters or fewer, 50 for 4, 10 for 5, 5 for 6 to 63 — to the P2PKH of the WAVE treasury (WAVE_TREASURY_ADDRESS). This method returns scripts; the caller builds the transaction, in Photonic’s shape (mintToken) and mainnet claimf644794b…’s:input 0 commit_txid:commit_vout the commit (scriptSig: sig, pubkey, envelope) input 1 commit_txid:commit_vout + 1 the seed for the contract's ref input 2 a plain P2PKH wallet UTXO funds the fee (f644794b: its commit tx's vout 2) vout 0 nft_script the name's claim token vout 1 contract_script the mutable contract vout 2 registration_fee_output (value, locking_script) vout 3+ change back to the wallet
FUND THE FEE FROM THE WALLET INPUT, NOT FROM THE COMMIT: then the fee is spent only by the reveal that registers the name, and a reveal that does not pay it (say, because the name was taken after the commit) leaves it in the wallet. Check that
pyrxd.glyph.wave.WaveResolver.check_available()still reports no confirmed registration just before broadcasting (it does not see a rival claim that is not yet mined);pyrxd glyph mint-nftdoes all of this.pyrxd.glyph.wave_rulescites the sources.pay_registration_fee=Falseregisters the name without paying. The published protocol and Photonic expect the fee; RXinDexer does not check it at registration, so the name still resolves; renewing it later takes a transaction that spends the claim token and pays the price to the treasury. It is also how a commit whose name was taken in the meantime is recovered: the reveal is then a duplicate the indexer does not register.registration_treasurypays another P2PKH address instead — the published treasury is a MAINNET address, and no testnet or regtest one exists. Seewave_registration_fee_for().nameis the name being claimed:"alice.rxd", or a bare"alice", which means the same. It must already be embedded incbor_bytes, with the fields Photonic Wallet’screateWaveNameMetadatawrites (packages/lib/src/wave.ts):{ "v": 2, "p": [2, 5, 11], "name": "alice.rxd", "type": "wave_name", "attrs": { "name": "alice", "domain": "rxd", "target": "<radiant_address>", "target_type": "address" } }
Use
pyrxd.glyph.wave.build_wave_metadata()to construct it, and pass the same payload toprepare_commit()first — which applies the same rule, so a claim that would be refused here is refused before its commit can be broadcast.REFUSED, by
refuse_unregistrable_wave_claim()— the one rule every WAVE write door calls — any payload outside the rule pyrxd writes claims by, which is stricter than the indexer’s: a label outside 3-63 characters of lowercasea-z,0-9and-(so pyrxd’s own ≤0.24.0 shape,attrs.name = "alice.rxd", which the indexer does not register, andaborAlice, which it does), a domain other thanrxd, a claim with noattrs.name, and a top-level orapp.dataname naming a different claim. Thennamemust be the name the indexer will read from the payload.allow_unregistrable_wave=Trueskips both checks and only confirmsnameis a name the payload carries. It is for ONE job: revealing a commit pyrxd ≤0.24.0 already broadcast, whose CBOR has the old shape. That commit can only be spent by revealing exactly those bytes, so refusing them would strand its value. WHETHER THE CLAIM SO REVEALED REGISTERS is the indexer’s rule, not pyrxd’s: a dottedattrs.name(the qualified name pyrxd ≤0.24.0 wrote when given one) does not, and the reveal only recovers the commit; a bare label the indexer accepts (≤0.24.0 wrote one when given a bare name such as"ab"or"Alice", which pyrxd now refuses) does, and owes the registration fee, whichregistration_fee_outputcarries — the fee followswave_registered_label(), the indexer’s rule.REBUILDING THOSE BYTES. Use the
cbor_bytesstored when the commit was made (CommitResult.cbor_bytes) if you have them.build_wave_metadata()no longer produces them, but every release that shipped it (v0.6.0 to v0.24.0) wrote the same bytes, and this reproduces them:from pyrxd.glyph.payload import encode_payload from pyrxd.glyph.types import GlyphMetadata, GlyphProtocol old_cbor, _ = encode_payload( GlyphMetadata( protocol=[GlyphProtocol.NFT, GlyphProtocol.MUT, GlyphProtocol.WAVE], attrs={ "name": qualified_name, # EXACTLY the string passed then: "alice.rxd" "domain": domain, # the text after its LAST ".", or "rxd" if none "target": target, "target_type": target_type, # "address" unless another was passed }, description=description, # the same description, "" if none was passed ) )
Checked byte for byte against the output of every tagged release from v0.6.0 to v0.24.0, recorded in
tests/fixtures/wave_build_metadata_v0_6_to_v0_24.json(tests/test_wave_claim_registers_with_the_indexer.py,TestTheRecoveryRecipe). Before broadcasting, confirmhash_payload(old_cbor) == extract_payload_hash_from_commit_script(commit_script). If the bytes differ in any way, the commit script’sOP_HASH256 <hash> OP_EQUALVERIFYfails and consensus rejects the reveal. Nothing is lost: the commit stays unspent, and the reveal can be rebuilt with the right bytes and tried again.Protocol requirement:
[NFT(2), MUT(5), WAVE(11)].The reveal shape is MUT’s, including its two-input requirement: the commit outpoint plus a seed outpoint at
commit_vout + 1that gives the mutable contract its own singleton ref. Seeprepare_mutable_reveal()— a WAVE registration built without the seed input is rejected by consensus, as every one built through 0.15.0 was.
- class pyrxd.glyph.GlyphClient[source]¶
Bases:
objectMint and transfer Glyph tokens over one ElectrumX client and one wallet.
Usage:
store = JsonFilePendingStore(Path("~/.pyrxd/pending").expanduser()) client = GlyphClient(electrumx, wallet, store=store) result = await client.mint_nft(metadata) receipt = await client.transfer_ft(ref, 250, recipient_pkh)
Transfer-only callers can skip the store:
client = GlyphClient(electrumx, wallet) receipt = await client.transfer_ft(ref, 250, recipient_pkh)
- Parameters:
client – an ElectrumX-style client —
await broadcast(raw_tx: bytes) -> txid,await get_transaction(txid),await get_utxos(script_hash), andawait get_history(script_hash), which anHdWalletcalls for the gap-limit scancollect_spendableruns before every spend.wallet – an
HdWallet, or anything exposingawait collect_spendable(client),privkey_for_address(address)andaddresses.store – where a
PendingMintlives between commit and reveal. Required for minting, unused by transfers.fee_rate – photons per byte, applied to every build. A rate above the overpay ceiling is always refused here. A sub-floor rate is refused here too when
storeis given — mints judge it in the constructor — and otherwise left to each build path, since the floor is a property of the chain.allow_below_relay_floor – accept a sub-floor
fee_rate, for chains whose floor really is lower —relay_floor_photons_per_byte()is a fixed mainnet constant, and a regtest node runs at a tenth of it. Required at construction whenstoreis given, since minting judges the rate in the constructor rather than per build. Since #458 this covers transfers as well as mints, on both the FT and NFT paths: it is threaded to the FT builder and tobuild_nft_transfertogether, because doing it for one alone would reintroduce the FT/NFT asymmetry that caused a release blocker on this surface.min_confirmations – depth required on a mint’s commit before its reveal.
confirmation_timeout_s – how long a reveal waits for the commit.
poll_interval_s – seconds between confirmation polls, forwarded to
GlyphMinter. Lower it for a chain that mines on demand; the default suits minutes-apart blocks.
- __init__(client, wallet, *, store=None, fee_rate=10000, allow_below_relay_floor=False, min_confirmations=1, confirmation_timeout_s=1800.0, poll_interval_s=10.0)[source]¶
- async airdrop_ft(ref, recipients, *, allow_overpay=False)[source]¶
Distribute
refto many recipients in one transaction, and broadcast.The orchestration behind this lived only in the CLI until now, so a library caller had to reimplement it or drive the builder directly.
Like its transfer siblings the returned txid is derived from the bytes that were signed, not from the node’s echo: a lying or buggy server could drop the transaction and echo a well-formed — even real and already-confirmed — txid, leaving a caller polling something unrelated to their tokens.
- Raises:
BroadcastEchoMismatch – the node echoed a txid other than the one the signed bytes hash to. Deliberately not a
ValidationError: those are raised before anything is sent, and a caller retrying on one would re-broadcast a distribution that may already have moved tokens.InsufficientFundsError – not enough of the token, or no plain-RXD UTXO to pay the fee. Raised before anything is signed or sent.
- Parameters:
ref (GlyphRef)
recipients (Sequence[AirdropRecipient])
allow_overpay (bool)
- Return type:
- async broadcast_timelock_reveal(build)[source]¶
Broadcast a reveal that was already built and shown to someone.
Split out from
reveal_timelock()so a caller that displayed a build can send those bytes. The CLI confirms a reveal by printing the key it is about to publish; callingreveal_timelockafter that prompt would build a second transaction and broadcast it instead — a confirmation showing one artifact and sending another, which is worse than no confirmation because it looks like one.The build already carries a checked plan; there is no way to construct a
TimelockRevealBuildthat has not been throughplan_timelock_reveal().- Parameters:
build (TimelockRevealBuild)
- Return type:
TimelockRevealReceipt
- async build_ft_airdrop(ref, recipients, *, allow_overpay=False)[source]¶
Build and sign a multi-recipient FT airdrop without broadcasting it.
One transaction, not N transfers: sequential transfers chain, each spending the previous one’s change, so a failure partway leaves the set half-delivered and the token’s ref alone cannot tell you which half. Output order follows
recipients.- Parameters:
ref (GlyphRef)
recipients (Sequence[AirdropRecipient])
allow_overpay (bool)
- Return type:
- async build_ft_transfer(ref, amount, to_pkh, *, allow_overpay=False)[source]¶
Build and sign an FT transfer without broadcasting it.
For callers that want to show the user what is about to be spent — which is exactly what the CLI does — or to inspect the transaction first.
- Parameters:
- Return type:
- async build_nft_transfer(ref, to_pkh, *, allow_overpay=False)[source]¶
Build and sign an NFT transfer without broadcasting it.
The singleton keeps its own value; the fee comes from a separate plain-RXD input. See
pyrxd.glyph.transfer.build_nft_transfer()for why this does not go throughGlyphBuilder.build_nft_transfer_tx().- Parameters:
- Return type:
- build_timelock_mint(*, name, content_type, plaintext, params, cek=None, recipients=(), locator=None)[source]¶
Encrypt and seal content without minting it. See
build_timelock_mint().Synchronous and network-free: this is pure construction, and it is exposed on the client so a caller can inspect the envelope — and take a copy of the CEK — before committing anything to a chain.
mint_timelocked_nft()is this plus the mint.
- async build_timelock_reveal(metadata, *, token_ref, cek, hint='', allow_early=False, allow_overpay=False)[source]¶
Build and sign a reveal without broadcasting it — the dry run.
For showing an operator exactly what would become public before it does, which is what
pyrxd glyph timelock-reveal --dry-rundoes with it. The plan inside the returned build has already passed the commitment check and the unlock gate; there is no way to obtain one of these that has not.
- async commit_ft(metadata, *, supply, treasury_pkh=None)[source]¶
Phase 1 of an FT deploy. See
GlyphMinter.commit_ft().Added alongside
reveal_ft()rather than after it: exposing only the reveal would let a caller FINISH a two-phase FT deploy through this facade that they could not START through it — half of a pair is the asymmetry the facade exists to remove, not a smaller version of the fix.- Parameters:
metadata (GlyphMetadata)
supply (int)
- Return type:
- async commit_nft(metadata, *, owner_pkh=None)[source]¶
Phase 1 of an NFT mint. See
GlyphMinter.commit_nft().- Parameters:
metadata (GlyphMetadata)
- Return type:
- async deploy_ft(metadata, *, supply, treasury_pkh=None)[source]¶
Deploy a fungible token with a full premine. See
GlyphMinter.deploy_ft().Spelled out rather than
*args, **kwargs: this is a published SDK, and the erased signature was the only one of the five facade methods that gave a caller no completion, no type checking, and aTypeErrorfrom inside the minter instead of at the call site.- Parameters:
metadata (GlyphMetadata)
supply (int)
- Return type:
- async mint_nft(metadata, *, owner_pkh=None)[source]¶
Commit and reveal an NFT singleton. See
GlyphMinter.mint_nft().- Parameters:
metadata (GlyphMetadata)
- Return type:
- async mint_timelocked_nft(*, name, content_type, plaintext, params, persist=None, cek=None, recipients=(), locator=None, owner_pkh=None)[source]¶
Seal
plaintextbehind a timelock and mint the NFT that commits to its key.The mint itself is
mint_nft()— the same two-phase commit/reveal, the same store, the same fee rules. What this adds is the envelope: the content is encrypted withchunked-aead-v1, the key’s SHA-256 goes on chain ascrypto.timelock, and the key comes back to the caller.The CEK and ciphertext are not recoverable from the chain. The mint carries a commitment to the key and a hash of the plaintext, nothing more; a mint cannot be re-run and there is no path from
sha256(cek)back tocek.KEY CUSTODY HAS TO PRECEDE THE COMMIT, so this method makes you say how. Supply either
persist— called with theTimelockMintBuildafter the envelope is built and before a single byte is broadcast — orcek, a key you already hold. With neither, this raises before touching the network.The refusal is not pedantry about defaults. Generating the key inside a call that then blocks on confirmation put the only copy of it in a local variable for as long as a Radiant block takes: a
NetworkError, aConfirmationTimeoutError, a cancellation or a kill in that window and the receipt is never constructed, while the pending store holds a resumable commit whose CBOR commits tosha256(cek). The documented recovery —reveal_nft()on that pending mint — then succeeds, and mints a token nobody can ever open. The advice this docstring used to give instead (“persist both halves of the receipt before doing anything else with it”) is advice a caller cannot act on: the receipt does not exist until after the window has closed.pyrxd glyph timelock-mintnever had this problem because it writes its files before broadcasting; the hook is that ordering, for the SDK.persistmay be sync or async, and anything it raises propagates with nothing broadcast. The build it receives carriescek,ciphertext,cek_hash,stubandmetadata— andmetadatais worth saving too: a commit that confirms while its reveal does not is spendable only by a reveal pushing byte-identical CBOR, which a build withrecipientscannot reproduce.See
build_timelock_mint()for the rest of the arguments.owner_pkhbehaves as it does onmint_nft(), defaulting to the funding key’s own hash.- Raises:
ValidationError – neither
persistnorcekwas given, no store was configured (minting needs one), or the parameters were refused. Raised before anything is broadcast.- Parameters:
- Return type:
TimelockMintReceipt
- property minter: GlyphMinter¶
The underlying
GlyphMinter.Built on first use so a transfer-only client never needs a store.
- Raises:
ValidationError – if this client was constructed without a store.
- async plan_timelock_reveal(metadata, *, token_ref, cek, hint='', allow_early=False)[source]¶
Check a reveal against the chain’s own clock, and return what it would publish.
The clock is read here rather than taken from the caller, which is the point of the method existing:
plan_timelock_reveal()cannot judge a lock it is not given a time for, and a caller passing its own number is a caller who can pass the wrong one.mode="block"— the tip height fromget_tip_height().mode="time"— the timestamp in the tip block’s header, not this process’s wall clock. A local clock can be wrong by any amount and nothing would say so, and the header timestamp is at least the unit the lock was written in. It is not exact — a block’s timestamp may run ahead of real time under consensus rules — so this is a gate against the obvious mistake, not a substitute for the operator knowing what they are publishing.
The clock is the SERVER’S, and this SDK does not authenticate it. Neither read is verified:
get_tip_heightchecks only that a non-negative integer came back andget_block_headeronly that 80 bytes did. Nothing checks the proof of work behind that height, links the header to one already known, or asks a second endpoint — and pyrxd’s default endpoints are third-party public servers. So an endpoint that overstates the tip obtains a permanent early reveal from a gate that reports itself satisfied, and one that merely lags refuses an honest holder pastunlock_at. Calling this “the chain’s clock” would be the more reassuring sentence and it would not be true: it is one server’s claim about the chain.What the reveal path does with that is show it. The returned plan carries
judged_at— the reading actually compared against — andpyrxd glyph timelock-revealprints it besideopens atin the pre-broadcast summary, so the operator can disagree with a number that would otherwise never have been on screen. “Summary”, not “prompt”: under--yesthere is no question, and for a while that meant no summary either, which made this whole sentence true only of the interactive run. It is now printed on every path that can broadcast — stdout in human mode, stderr under--json/--quiet— and recorded again on the receipt afterwards. An SDK caller who needs more than that should pass a clock they trust toplan_timelock_reveal()directly.Everything the plan is checked for happens in the underlying function; see its docstring. This adds only the clock.
- async reveal_ft(pending, *, fee_rate=None, allow_below_relay_floor=None, allow_overpay=False)[source]¶
Phase 2 of an FT deploy. See
GlyphMinter.reveal_ft().The FT counterpart of
reveal_nft(), and it exists for the sharper half of the reason that one does. A two-phase FT deploy could not be FINISHED through this facade at all: a caller who committed had to reach past it into.minter. The commit output is a hashlock with no owner-only spend path, so the phase a caller most needs to resume was the one not exposed.allow_below_relay_floormust stayNone-defaulted, notFalse— the same trap documented at length onreveal_nft().Nonemeans “inherit the constructor”;Falsemeans “the caller re-asserted the floor for this reveal”. AFalsedefault here would forward a deliberate override on every ordinary call, so a client built withallow_below_relay_floor=Truewould commit and then refuse to reveal, stranding the commit and everything funded into it.- Parameters:
pending (PendingMint)
fee_rate (int | None)
allow_below_relay_floor (bool | None)
allow_overpay (bool)
- Return type:
- async reveal_nft(pending, *, fee_rate=None, allow_below_relay_floor=None, allow_overpay=False)[source]¶
Phase 2 of an NFT mint. See
GlyphMinter.reveal_nft().The three overrides are forwarded because without them the minter’s escape hatch is unreachable from this facade: a commit whose stored fee rate now sits below a risen relay floor cannot be revealed at all, and re-pricing upward is only possible when the commit holds enough to pay it.
allow_below_relay_floormust stayNone-defaulted, notFalse. The minter readsNoneas “the caller said nothing, inherit the constructor” andFalseas “the caller re-asserted the floor for this reveal”. Defaulting toFalsehere forwarded a deliberate override on every ordinary call, so a client built withallow_below_relay_floor=Truecommitted and then refused to reveal — stranding the commit, which is a hashlock with no owner-only spend path. That is exactly the failure the constructor flag exists to prevent, reintroduced one layer above it by a default that looked harmless.- Parameters:
pending (PendingMint)
fee_rate (int | None)
allow_below_relay_floor (bool | None)
allow_overpay (bool)
- Return type:
- async reveal_timelock(metadata, *, token_ref, cek, hint='', allow_early=False, allow_overpay=False)[source]¶
Publish the CEK on chain, and broadcast.
Irreversible. After this relays, anyone holding the ciphertext can decrypt it, forever. There is no unreveal and no second reveal.
Two mistakes are refused before anything is sent, both by
plan_timelock_reveal(): a CEK that is not the one this token committed to, and a reveal beforeunlock_atwithoutallow_early. Both areValidationErrorsubclasses, so they land with everything else raised pre-broadcast.- Raises:
CekCommitmentMismatch –
sha256(cek)is not the token’s commitment. Publishing it would spend the reveal and leave the payload unreadable for good.TimelockNotExpired – the lock has not expired and
allow_earlywas not set.BroadcastEchoMismatch – the node echoed a txid other than the one the signed bytes hash to. Deliberately not a
ValidationError— a caller retrying on one would re-publish a key that may already be public.InsufficientFundsError – no plain-RXD UTXO large enough to fund the reveal. Raised before anything is signed.
- Parameters:
- Return type:
TimelockRevealReceipt
- async transfer_ft(ref, amount, to_pkh, *, allow_overpay=False)[source]¶
Send
amountunits ofreftoto_pkh, and broadcast.The recipient output is sized from
amount; the fee is paid from a separate plain-RXD input, because an FT output’s value is its unit count and taking the fee from the token would short the recipient.- Raises:
InsufficientFundsError – not enough of the token, or no plain-RXD UTXO to pay the fee. Raised before anything is signed or sent.
ValidationError – inputs span multiple keys, the builder refused the parameters, or change would have been paid to the miner.
- Parameters:
- Return type:
- async transfer_nft(ref, to_pkh, *, allow_overpay=False)[source]¶
Send the NFT singleton
reftoto_pkh, and broadcast.The singleton’s value crosses unchanged and the fee is paid from plain RXD. There is no
amount: a singleton is indivisible, and the value on an NFT output is dust rather than a quantity.- Raises:
InsufficientFundsError – this wallet does not hold the NFT, or has no plain-RXD UTXO large enough to pay the fee. Raised before anything is signed or sent.
ValidationError – the signed transaction does not pay for its own size.
- Parameters:
- Return type:
NftTransferReceipt
- class pyrxd.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:
<0x04 nonce(4)> <0x20 inputHash(32)> <0x20 outputHash(32)> <OP_0> → 72 bytes <0x08 nonce(8)> <0x20 inputHash(32)> <0x20 outputHash(32)> <OP_0> → 76 bytes
Where:
nonce— little-endian PoW nonce found by the miner. Its width does NOT say whether the contract is V1 or V2. Neither covenant checks it — each concatenates the nonce into the proof-of-work preimage (V1 epilogue5a 7a 7e, V2 Part A5e 7a 7e) — and V1 mints on mainnet use both widths: 465 of 473 mints of V1 contracts in a sample collected on 2026-09-23 pushed 8 bytes. The contract script the input spends is what tells V1 from V2 (DmintState.from_script). This payload used to carry aversion_hintread off the width, which called those V1 mints “v2”; it carriesnonce_widthinstead.inputHash—SHA256d(funding_input_locking_script). NOT a preimage half; the on-chain covenant recomputesSHA256(inputHash || outputHash)from these literal pushes.outputHash—SHA256d(OP_RETURN_msg_script at vout[2]).OP_0— the sentinel push the V1/V2 covenant requires.
Verified against mainnet V1 mint
146a4d68…f3cand the V1 mintc9fdcd34…e530.Returns a dict with
nonce_hex,input_hash,output_hash,nonce_width(4 or 8) andscriptsig_length— orNoneif the scriptSig doesn’t match the canonical 4-push shape.Catches
Exceptionbroadly because every call site crosses a trust boundary: scriptSigs from network-fetched txs are attacker- controlled. Non-mint inputs (P2PKH funding inputs, plain RXD spends, etc.) returnNone.
- class pyrxd.glyph.GlyphMetadata[source]¶
Bases:
objectCBOR payload for a Glyph token.
- __init__(protocol, name='', ticker='', description='', token_type='', main=None, attrs=<factory>, loc='', loc_vout=None, loc_hash='', decimals=0, image_url='', image_ipfs='', image_sha256='', v=None, dmint_params=None, creator=None, royalty=None, policy=None, rights=None, created='', commit_outpoint='', timelock=None, encrypted_main=None, crypto=None, source_cbor=None, container_refs=(), author_refs=())¶
- Parameters:
name (str)
ticker (str)
description (str)
token_type (str)
main (GlyphMedia | None)
loc (str)
loc_vout (int | None)
loc_hash (str)
decimals (int)
image_url (str)
image_ipfs (str)
image_sha256 (str)
v (int | None)
dmint_params (DmintCborPayload | None)
creator (GlyphCreator | None)
royalty (GlyphRoyalty | None)
policy (GlyphPolicy | None)
rights (GlyphRights | None)
created (str)
commit_outpoint (str)
timelock (TimelockSpec | None)
encrypted_main (EncryptionMetadata | None)
crypto (CryptoMetadata | None)
source_cbor (bytes | None)
- Return type:
None
- 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.
- loc_vout: int | None = None¶
An INTEGER
loc, kept rather than discarded. READ PATH ONLY — never encoded.pyrxd reads
locas a text URI. Photonic reads it as a REF VOUT: an integerlocpoints at one of the token’s own refs, whose payload Photonic fetches and MERGES into this one (packages/app/src/electrum/worker/NFT.ts:988-1010). The two implementations do not mean the same thing by the field, and pyrxd was dropping the integer form with a warning — so a token whose real metadata lives in a second payload decoded as though it simply had noloc, indistinguishable from one that never had any. That is a silent partial read of third-party data.THE MERGE IS NOT IMPLEMENTED. Following the pointer means fetching another output mid-decode, which this decode path deliberately does not do. Surfacing the vout is the honest half: a caller can see the payload is incomplete and go and get the rest.
Nonemeans no integerlocwas present, not that a merge was performed.
- 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(Path("~/.pyrxd/pending-mints").expanduser()) minter = GlyphMinter(client, wallet, store) result = await minter.mint_nft(metadata)
or, resumably:
pending = await minter.commit_nft(metadata) # persisted, then broadcast ... # crash, reboot, next week pending = store.load(commit_txid) result = await minter.reveal_nft(pending)
- Parameters:
client – an ElectrumX-style client —
await broadcast(hex) -> txidandawait get_transaction_verbose(txid) -> dict. Duck-typed, matchingwait_for_confirmation().wallet – an
HdWallet. Exactly two methods are used —await collect_spendable(client) -> [(utxo, address, privkey)]to fund the commit, andprivkey_for_address(address)to re-derive the reveal’s signing key. Duck-typed likeclient, and deliberately NOT hidden behind a coin-source Protocol:HdWalletis the only real implementer, and a one-implementer Protocol is indirection with nothing on the other side. The two-method surface is small enough to stand in for directly — seeexamples/regtest_quickstart.py, which drives the minter from a single regtest key.store – where the
PendingMintis kept between phases. Required.fee_rate – photons per byte for both transactions.
allow_below_relay_floor – accept a
fee_rateunder the relay floor, for chains whose floor really is lower — a regtest node runs at a tenth of mainnet’s. Applies to the reveal as well, so a minter built this way can finish what it starts. Leave it False against mainnet.min_confirmations – depth required on the commit before the reveal is built. See
DEFAULT_MINT_CONFIRMATIONS— the default is convention.confirmation_timeout_s – how long
reveal_*waits before raisingConfirmationTimeoutError. ThePendingMintsurvives a timeout, so the reveal can be retried.poll_interval_s – seconds between confirmation polls; must be > 0. The default suits a chain whose blocks are minutes apart; a regtest node that mines on demand wants a much smaller value, and until this was exposed there was no way to ask for one — the reveal always slept the full 10s default, so confirmation_timeout_s below that could not take effect until a whole interval had elapsed. Zero is refused: the poll happens before the sleep, so it would busy-loop the server for the whole timeout.
- __init__(client, wallet, store, *, fee_rate=10000, allow_below_relay_floor=False, min_confirmations=1, confirmation_timeout_s=1800.0, poll_interval_s=10.0)[source]¶
- async commit_ft(metadata, *, supply, treasury_pkh=None)[source]¶
Broadcast the commit for a fungible-token deploy with a full premine.
- Parameters:
metadata (GlyphMetadata) – must carry
FT. DMINT is refused — a dMint deploy emits parallel contract UTXOs and belongs toprepare_dmint_deploy().supply (int) – units issued, all placed on the reveal’s token output. Radiant convention is 1 photon = 1 FT unit, so this is also that output’s value, and it must clear pyrxd’s 546-unit decimals-mistake guard (a pyrxd policy, not a chain limit — Radiant’s output floor is 1 photon).
treasury_pkh (Hex20 | bytes | None) – who receives the premine. Defaults to the funding key’s PKH.
- Raises:
ValidationError – on an unsupported protocol mix or a sub-dust supply.
- Return type:
- async commit_nft(metadata, *, owner_pkh=None)[source]¶
Broadcast the commit for an NFT singleton mint.
The
PendingMintis persisted and read back before the broadcast.- Parameters:
metadata (GlyphMetadata) – must carry
NFTand none of the tags whose reveal has a different shape (MUT, WAVE, DMINT) — those are refused rather than committed to a reveal this module cannot build.CONTAINERis supported: a collection’s reveal is this same single-output NFT shape.owner_pkh (Hex20 | bytes | None) – recipient. Defaults to the funding key’s own PKH.
- Raises:
ValidationError – on an unsupported protocol mix.
InsufficientFundsError – if no single UTXO can fund the mint, or if the measured reveal could not pay its fee out of the commit. Both are raised before anything is broadcast.
- Return type:
- async deploy_ft(metadata, *, supply, treasury_pkh=None)[source]¶
commit_ft()thenreveal_ft(). Seemint_nft().- Parameters:
metadata (GlyphMetadata)
supply (int)
- Return type:
- async mint_nft(metadata, *, owner_pkh=None)[source]¶
commit_nft()thenreveal_nft(), waiting for the commit in between.Convenient, not different: the commit is persisted before broadcast here too, because the store is a constructor dependency rather than a per-call keyword. That is the reason it sits on the constructor — a single-call helper cannot forget to pass it.
Note this blocks for as long as the commit takes to confirm. If that is a problem, drive the two phases yourself.
- Parameters:
metadata (GlyphMetadata)
- Return type:
- async reveal_ft(pending, *, fee_rate=None, allow_below_relay_floor=None, allow_overpay=False)[source]¶
Wait for the commit, then broadcast the FT deploy reveal.
Mirrors
reveal_nft(); the reveal’s token output carries the whole premined supply recorded inPendingMint.carrier_value.- Parameters:
pending (PendingMint)
fee_rate (int | None)
allow_below_relay_floor (bool | None)
allow_overpay (bool)
- Return type:
- async reveal_nft(pending, *, fee_rate=None, allow_below_relay_floor=None, allow_overpay=False)[source]¶
Wait for the commit, then broadcast the NFT reveal.
The stored record is re-validated against the commit script before anything is built — see
_assert_payload_still_matches(). Once the reveal is reported confirmed the record is archived (PendingStore.archive()), never deleted; on failure it is kept where it is so the reveal can be retried. An archived record (JsonFilePendingStore.load_archived) can be passed here again — through another server, if the one that reported the reveal confirmed was wrong.- Parameters:
pending (PendingMint)
fee_rate (int | None)
allow_below_relay_floor (bool | None)
allow_overpay (bool)
- Return type:
- property store: PendingStore¶
The configured
PendingStore— resume through it after a crash.
- class pyrxd.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, *, strict=False)[source]¶
Return all Glyph outputs currently owned at address.
Reads the script hashes the address’s token outputs are listed under (
owned_token_script_hashes()), not its P2PKH hash: a Radiant ElectrumX lists a token output under its script with the refs zeroed, so the P2PKH hash lists the address’s plain outputs only. A token the server lists here whose owner is not address’s key is refused as a server inconsistency, not returned.- Parameters:
address (str) – Base58Check-encoded P2PKH address.
strict (bool) – See
scan_script_hash().
- Returns:
Typed Glyph objects.
metadataisNonewhen the reveal transaction cannot be located or carries no readable envelope (see_resolve_reveal_metadata()) — including transfer outputs whose commit-output history is unavailable.- Return type:
- async scan_script_hash(script_hash, *, strict=False)[source]¶
Return all Glyph outputs for script_hash.
Fetches UTXOs, raw transactions, and (where available) reveal transaction metadata, then constructs typed GlyphNft / GlyphFt objects.
What the server says is checked against the transaction it serves, which
get_transactionbinds to the txid. A UTXO whose output is not at script_hash (or does not exist), or is listed twice, is a server inconsistency, and so is a token whose owner is not the addressscan_address()was asked about. An FT’s amount is the output’s value in that transaction, never the server’svalue. That a listed output is still unspent is the server’s word: nothing here checks it.A UTXO whose raw transaction cannot be fetched or parsed, or that is a server inconsistency, is logged and left out by default. For a failed read the result is then a lower bound on what the script hash holds; an inconsistent item was never shown to be held here.
strict=TrueraisesNetworkErrorinstead; use it when the result is shown as everything held (pyrxd glyph listdoes). A metadata lookup that fails does not count: it leaves the token in the result withmetadata=None.Concurrency: UTXO raw-tx fetches and reveal-metadata resolutions both run in parallel via
asyncio.gather. Pre-fix (closes ultrareview re-review N17) the reveal-metadata path was inside the per-utxo loop and serialised one round-trip per glyph; for a 100-glyph wallet that meant ~100x the latency of the now- batched version. Metadata is resolved once per distinct ref, so an FT split across many UTXOs costs one resolution, not N.
- class pyrxd.glyph.HeightReport[source]¶
Bases:
objectWhere ONE endpoint places the mark and each step of the name’s chain.
judge_name_at_markneeds at least two of these from DIFFERENT sources, agreeing on every height it compares, before it will say which target was in force at the mark’s block. A report is the endpoint’s word, labelled — the judge does the comparing, so no caller can “corroborate” by assertion.None/ an absent txid means THIS endpoint does not place it in a block (it reports it unconfirmed, or does not list it at all). Two endpoints that both leave a step unplaced agree; one that places it and one that does not disagree, and that degrades.- __init__(source, mark_height, step_heights=<factory>, error='', mark_header_bound=False)¶
- error: str = ''¶
Why this endpoint’s heights could not be obtained, if they could not. An error is not a report that nothing is confirmed, and must not be read as one: it degrades, naming it.
- mark_header_bound: bool = False¶
Truewhenmark_heightwas bound to this endpoint’s own header (header_bound). The verdict’s caveat says the mark was header-checked only when EVERY report says so — a default of False makes the weaker sentence the one a caller gets without asking.
- 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.directory is used as given:
~is NOT expanded, so passPath("~/.pyrxd/pending-mints").expanduser(), not the bare string, or the records land in a directory literally named~under the current one (#755).- ARCHIVE_DIRNAME: ClassVar[str] = 'done'¶
a subdirectory
list_pending()does not read.- Type:
Where
archive()moves a record
- archive(commit_txid)[source]¶
Move the live record to
done/: a rename, so it keeps its 0600 mode, into a 0700 directory. Does nothing if there is no live record. SeePendingStore.archive().Raises
ValidationError, leaving the record where it is, whendone/is not a real directory (archive_directory_problem(), which reads it withlstat). On POSIX thechmodand the rename act on a descriptor ofdone/opened withO_NOFOLLOW(_open_archive()), never on its path, so neither can follow a link — not even one swapped in after thelstat(lane D F2: achmodof the path followed a link and made a foreign directory 0700).- Parameters:
commit_txid (str)
- Return type:
None
- classmethod archive_directory_problem(directory)[source]¶
Why the archive of a store at
directoryis unusable, orNone. Creates nothing.Read with
lstat, so a symbolic link is seen as a link:done/must be absent or a real directory. A regular file there makes themkdirfail; a link can lead to another filesystem (a rename then fails withEXDEV) or to a directory that is not the store’s (on3cb57e27achmodof the path followed one and made it 0700; lane D F2).
- archive_problem()[source]¶
Why
archive()could not work in this store, orNone.GlyphMinterasks before it broadcasts the commit and again before the reveal, and refuses on an answer: an archive that fails after a reveal is reported confirmed would otherwise be found only when the mint is finished. The default has nothing that can fail.- Return type:
str | None
- delete(commit_txid)[source]¶
Drop the record. Must not raise if it is already gone.
GlyphMinternever calls this: seearchive(). It is here for a caller who has seen the reveal on chain for themselves and wants the record gone.- Parameters:
commit_txid (str)
- Return type:
None
- list_archived()[source]¶
Commit txids with an archived record. Refuses a
done/that is not a real directory.
- load(commit_txid)[source]¶
Return the stored record, or raise
PendingMintNotFound.- Parameters:
commit_txid (str)
- Return type:
- load_archived(commit_txid)[source]¶
The archived record for
commit_txid;PendingMintNotFoundif there is none.A read: it changes no mode and builds no second store (lane D N1 — a store built on
done/chmodded it by path, through a link). Refuses adone/that is not a real directory, asarchive()does.- Parameters:
commit_txid (str)
- Return type:
- restore(commit_txid)[source]¶
Move an archived record back to the live directory, to reveal it again — renamed out of a
done/descriptor opened withO_NOFOLLOW, asarchive()renames into one.- Parameters:
commit_txid (str)
- Return type:
None
- save(pending)[source]¶
Persist pending, overwriting any record under the same commit txid.
- Parameters:
pending (PendingMint)
- Return type:
None
- class pyrxd.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: it was not checked against any block header, and nothing checks proof-of-work or merkle inclusion, so an endpoint that lies about the height moves the point in time this answer is about', height_is_verified=False, header_bound=False, blockhash=None, verified_confirmations=None)¶
- blockhash: str | None = None¶
The block hash the endpoint named for the transaction (verbose
blockhash), lowercase display hex — set only whenheader_bound, i.e. when the header atheighthashed to it.Noneotherwise: an unbound hash would be a second unchecked claim.
- caveat: str = 'height reported by the endpoint and NOT verified: it was not checked against any block header, and nothing checks proof-of-work or merkle inclusion, so an endpoint that lies about the height moves the point in time this answer is about'¶
- header_bound: bool = False¶
Truewhenheightwas bound to the endpoint’s own header (the header atheighthashes to the block the endpoint named). A check of the endpoint against ITSELF — which is whyheight_is_verifiedstays False. Carried so a verdict built on the height can say truthfully whether the height was header-checked, rather than assuming either way.
- height_is_verified: bool = False¶
a
MarkAnchoris an endpoint’s word. The DISPLAY dict’sheight_is_verifiedcan becomeTrueonly throughwith_block_verification(), given a VERIFIED outcome ofpyrxd.glyph.mark_block.verify_mark_block().- Type:
Always
Falsehere
- property provisional: bool¶
Below the caller’s bar — real, but shallow enough to be reorged out.
Judged against the PROVED depth when there is one (
verified_confirmations), else the endpoint’sconfirmations. A proved depth reaches the floor by construction, so it can only lift a mark the endpoint reports as shallow; one below the floor (built by hand) is provisional, whatever the endpoint reports.
- property usable_for_point_in_time: bool¶
Has a block, and is buried to the depth the caller asked for.
- verified_confirmations: int | None = None¶
the default, and what every anchor an endpoint’s reply builds carries. Set only from a VERIFIED block verification of this height that reached
min_confirmations(proven_depth()), so it can move the depth verdict from the endpoint’s word to proved and never below the floor.confirmationsstays the endpoint’s figure either way.- Type:
The depth pyrxd PROVED for this block (
with_proven_depth()), orNone
- 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, *, registration_fee_output)¶
- registration_fee_output: WaveRegistrationFee | None¶
The WAVE registration fee this reveal must pay, or
None. AsRevealScripts.registration_fee_output; for a WAVE claim that is vout 2, afternft_script(vout 0) andcontract_script(vout 1), where Photonic puts it.
- 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:
- wave_fee¶
for a payload that registers a WAVE name, what the minter chose to do about the registration fee:
"pay"or"decline".Nonefor every other record.pyrxd glyph resume-minthonours it, so an opt-out made at mint time survives a timeout or a crash instead of reverting to the paying default.- Type:
str | None
- wave_treasury¶
the address the fee is paid to when the minter named one (
--wave-treasury);Nonefor the published treasury. Only withwave_fee="pay".- Type:
str | None
- network¶
the network the commit was broadcast on (
mainnet,testnetorregtest), orNonewhen the writer did not say.pyrxd glyph mint-nftalways says: one pending-mints directory serves every network a wallet is used on.- Type:
str | None
wave_fee,wave_treasuryandnetworkare the writer’s word, kept for comparison.pyrxd glyph resume-mintnever lets them decide what is paid: a record is a file that can be changed after the mint, so the fee choice is stated on the resume command line and the record only cross-checks it.- __init__(commit_txid, commit_vout, commit_value, commit_script, cbor_bytes, owner_pkh, is_nft, carrier_value, fee_rate, funding_address, wave_fee=None, wave_treasury=None, network=None)¶
- 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.- archive(commit_txid)[source]¶
Retire the record of a mint whose reveal was REPORTED confirmed — without destroying it.
GlyphMintercalls this, neverdelete(), once its reveal is reported confirmed. “Confirmed” is the server’s word: a server that echoes a reveal it never relayed and reports it confirmed would otherwise have the only copy of the payload the commit can be spent with deleted while the commit is still unspent (#736, round 3). An archived record can be revealed again, through another server.This default KEEPS the record where it is, so
list_pending()goes on reporting it: a store that cannot archive errs toward keeping. Override it to move the record wherelist_pending()does not look (JsonFilePendingStoremoves it todone/). Must not raise if the record is already gone.- Parameters:
commit_txid (str)
- Return type:
None
- archive_problem()[source]¶
Why
archive()could not work in this store, orNone.GlyphMinterasks before it broadcasts the commit and again before the reveal, and refuses on an answer: an archive that fails after a reveal is reported confirmed would otherwise be found only when the mint is finished. The default has nothing that can fail.- Return type:
str | None
- abstractmethod delete(commit_txid)[source]¶
Drop the record. Must not raise if it is already gone.
GlyphMinternever calls this: seearchive(). It is here for a caller who has seen the reveal on chain for themselves and wants the record gone.- Parameters:
commit_txid (str)
- Return type:
None
- 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}— checked, not assumed: any other shape, or the indexer’s{"error": ...}, raisesRxinDexerError.
- 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}— checked, not assumed: any other shape, or the indexer’s{"error": ...}, raisesRxinDexerError.
- 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 a lapsed name is not one the address holds in any useful sense.These are names the address HOLDS (the
WRowner index), not names whose target points at it: a holder can set a name’s target to any address (#729).
- 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.
GlyphMinternever calls this: seearchive(). It is here for a caller who has seen the reveal on chain for themselves and wants the record gone.- Parameters:
commit_txid (str)
- Return type:
None
- 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.
nameis carried VERBATIM, not normalised. On a Photonic claim, and on one pyrxd builds now, it is the bare label ("alice") anddomainholds the rest. On a claim pyrxd built through 0.24.0 it is the qualified name ("alice.rxd"), which RXinDexer does not index (see the module docstring). A reader that appendsdomainmust check for a.first, or it printsalice.rxd.rxdfor the older 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, height_sources=())¶
- height_sources: tuple[str, ...] = ()¶
one label per distinct source. Empty on a degrade.
- Type:
The sources whose block heights agreed, on a form-2 verdict
- ref: str¶
The glyph this is ABOUT, always. If the name→glyph binding is wrong, this stays true of the ref and simply says nothing about the name — which is the correct failure.
- binding_source: str¶
How the name→glyph binding was obtained. Compared with the anchor’s source by source key.
- 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.WaveRegistrationFee[source]¶
Bases:
objectThe output a reveal that registers a WAVE name pays to the protocol treasury.
Put
TransactionOutput(Script(fee.locking_script), fee.value)in the reveal after the token outputs and before change — for a WAVE claim, vout 2, after the NFT and the mutable contract — and FUND IT FROM A PLAIN WALLET INPUT added to the reveal, not from the commit. That is Photonic’s shape (see the module docstring), and it keeps the fee in the wallet, where it can simply not be spent, until the moment the name is registered.valueandlocking_scriptare DERIVED from the label and the treasury address and cannot be passed in, so a fee at the wrong tier, or paying a script that is not the treasury’s P2PKH, is not a value this type can hold.- __init__(label, treasury_address='1GrwkQNJfjbEJjH25heszNZLpbZou8nfXG')¶
- describe()[source]¶
"5 RXD (500,000,000 photons) to 1Grw…", for a confirmation or an error.- Return type:
- property is_published_treasury: bool¶
Whether this pays the published mainnet treasury,
WAVE_TREASURY_ADDRESS.
- treasury_address: str = '1GrwkQNJfjbEJjH25heszNZLpbZou8nfXG'¶
the published MAINNET treasury unless the caller names another.
- Type:
Where the fee goes
- label: str¶
The label the claim registers, as the indexer reads it (
"alice")."alice.rxd"is accepted and stored as"alice".
- value: int¶
wave_registration_price()oflabel.- Type:
- 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 the indexer has no confirmed registration of name.
RXinDexer answers from the blocks it has indexed, not from the mempool (
electrumx/server/glyph_api.py:1498-1515callsWaveIndex.check_available,electrumx/server/wave_index.py:1773-1800atca8a6a4e), so a rival claim that is broadcast but not yet mined is not seen: True is “no confirmed registration”, not “nobody else is registering it”.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 not an integer in
0..MAX_BURN_AMOUNT, 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, *, registration_fee='unstated')[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.
registration_fee (WaveRegistrationFee | Literal['unstated'] | None) – Required only when
cbor_bytescarries WAVE inpand would register: see below. Any other payload ignores it.
- Return type:
An update envelope whose
pcarries WAVE is read by RXinDexer as a REGISTRATION ofattrs.name, not as an update:process_txsends everything WAVE-marked down the claim path (electrumx/server/wave_index.py:684-699atca8a6a4e). While the name is held and has not lapsed that registers nothing (a duplicate,wave_index.py:767-781); once it has lapsed, or for another name, it registers a new claim at this transaction’s vout 0 (:730,:782-807). So a WAVE-marked update is held to the claim rule and, like a reveal, must say what it pays for the name:registration_fee=aWaveRegistrationFeewhose output goes in the update, orNone. An update whose payload carries nop— the{attrs: {...}}shape the indexer’s own comment describes for amod(wave_index.py:686-691) — is not a claim, and neither of these applies to it.
- 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='', expires=None)[source]¶
Construct a WAVE claim’s
GlyphMetadatawith Photonic’s fields.- Parameters:
qualified_name (str) –
"<label>.rxd", or a bare"<label>", which means the same. The label must meet the rule inpyrxd.glyph.wave_rules— 3-63 characters of lowercasea-z,0-9and-, no leading or trailing-, and--only in anxn--punycode label — and the domain must be exactlyrxd. Anything else is refused here, because the indexer would decline the claim after it had confirmed.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).expires (int | None) – optional unix-seconds
attrs.expires. Photonic always writes one (now + 2 years); pyrxd writes it only when given, so the bytes do not depend on the clock. It is display-level on both sides: RXinDexer stamps the real term from the registration BLOCK time and ignores this field for expiry.
- Return type:
The fields are those of Photonic’s
createWaveNameMetadata(v2, protocol[NFT, MUT, WAVE], top-levelname= the qualified name,type="wave_name",attrs= bare label, domain, target, target type). Pass the result toGlyphBuilder.prepare_commit()and thenGlyphBuilder.prepare_wave_reveal(). Registering the name pays the WAVE registration fee: both return it asregistration_fee_output(wave_registration_price(qualified_name)photons to the WAVE treasury), and the reveal must carry that output unless the caller passespay_registration_fee=False. Seepyrxd.glyph.wave_rules.THE LABEL GOES IN
attrs.name, NOT THE QUALIFIED NAME. Through 0.24.0 this function wrote the string it was given intoattrs.nameand left the top level empty. RXinDexer registers a claim fromattrs.nameand itsvalidate_wave_namerefuses the., so a claim built from a qualified name ("alice.rxd", the form its 0.24.0 docstring showed) registered nothing (#728). One built from a bare label ("alice") wroteattrs.name = "alice", which registersalice.rxd. Both were checked by running the indexer’s own code at the pinned commit (tests/test_wave_fee_matches_the_pinned_indexer.py), not against a live indexer.There is no homograph option. The label rule is ASCII-only, so a NON-ASCII look-alike label is refused by construction; a non-ASCII name is written as its
xn--punycode. An all-ASCII look-alike (paypa1) is not refused, and #698’s check did not refuse one either.
- 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 — including apyrxd ≤0.24.0 claim whose qualified attrs.name RXinDexer does not index)
[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)¶
LEGACY ASERT target — the integer power-of-2 stepper baked into pre-resync contracts.
Off-chain mirror of
pyrxd.glyph.dmint.builders._build_asert_daa_legacy(), the ASERT bytecode pyrxd emitted before 2026-09-16 (upstream replaced it with ASERT-v2 on 2026-06-19,ed53cd41). A contract deployed under it MUST keep being mined with THIS formula — the covenant recomputes the next target with the bytecode it was born with, and rejects any other value.build_dmint_mint_txdispatches here whendetect_contract_daa_bytecode()reportsDaaBytecodeVersion.LEGACY; new deploys usecompute_next_target_asert_v2().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).Both subtractions go through
_script_int64(), as in the v2 mirror: for atarget_timenear the int64 limit the on-chain OP_SUB aborts, so this raisesValidationErrorrather than return a target the contract cannot compute.Note
V2-only DAA. V1 has no DAA (fixed difficulty).
- pyrxd.glyph.compute_next_target_asert_legacy(current_target, last_time, current_time, target_time, half_life)[source]¶
LEGACY ASERT target — the integer power-of-2 stepper baked into pre-resync contracts.
Off-chain mirror of
pyrxd.glyph.dmint.builders._build_asert_daa_legacy(), the ASERT bytecode pyrxd emitted before 2026-09-16 (upstream replaced it with ASERT-v2 on 2026-06-19,ed53cd41). A contract deployed under it MUST keep being mined with THIS formula — the covenant recomputes the next target with the bytecode it was born with, and rejects any other value.build_dmint_mint_txdispatches here whendetect_contract_daa_bytecode()reportsDaaBytecodeVersion.LEGACY; new deploys usecompute_next_target_asert_v2().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).Both subtractions go through
_script_int64(), as in the v2 mirror: for atarget_timenear the int64 limit the on-chain OP_SUB aborts, so this raisesValidationErrorrather than return a target the contract cannot compute.Note
V2-only DAA. V1 has no DAA (fixed difficulty).
- pyrxd.glyph.compute_next_target_asert_v2(current_target, last_time, current_time, target_time, half_life)[source]¶
ASERT-v2 next target — the fractional, symmetric, damped retarget of CURRENT deploys.
Off-chain mirror of
pyrxd.glyph.dmint.builders._build_asert_daa_v2(), which is the canonical PhotonicbuildAsertDaaBytecode(script.tsatbecf41a), and an exact port of the referencecomputeAsertV2Targetinpackages/lib/src/dmintDaaV2.ts(same commit) — bigint arithmetic there, so the only semantic to carry over is that BigInt and OP_DIV both truncate toward zero:excess = (current_time - last_time) - target_time driftFp = trunc((excess * RADIX) / half_life) # RADIX = 2^16, signed driftFp = clamp(driftFp, -RADIX/4, +RADIX/4) # ±25%/mint damping t = min(current_target, MAX_TARGET/4) # difficulty floor 4 new = clamp(t + (t / RADIX) * driftFp, 1, MAX_TARGET/4)
_trunc_divis load-bearing: OP_DIV truncates toward zero while Python//floors, and they differ for the negativeexcessof an early block (e.g.excess = -1, half_life = 240→ trunc(-65536/240) = -273; floor gives -274). A floor here would recreate a target the covenant does not, and the mint is rejected.int64 bounds, every intermediate (the covenant aborts, not wraps, outside
[-(2^63-1), 2^63-1]; this mirror raisesValidationErrorat the same points via_script_int64()so it never predicts an unmineable state):timeDelta = current_time - last_time: for an ASERT/LWMA mintbuild_dmint_mint_txrequires bothcurrent_timeand the state’slast_timeto be readable lastTimes (is_readable_last_time:[2^23, 2^31 - 1]), so|timeDelta| <= 2^31 - 1in either direction.excess = timeDelta - target_time:target_timeis a state script number in[1, 2^63 - 1], soexcessis in[-(2^63 - 1) - (2^31 - 1), 2^31 - 2]and the OP_SUB underflows only fortarget_time > 2^63 - 1 + timeDelta— checked.excess * RADIX:|excess| <= 2^47 - 1⇒|excess * 2^16| <= 2^63 - 2^16, representable;|excess| >= 2^47⇒|product| >= 2^63, which OP_MUL aborts. Positive excess never reaches it (excess <= 2^31 - 2); negative excess reaches it only fortarget_time >= 2^47 + timeDelta(≈ 1.4e14 s ≈ 4.5 Myr) — checked. Photonic’s proof (|excess| <= ~4.3e9→|product| <= ~2.8e14 < 2^63) is the realistic-deploy case of the same bound.driftFp = trunc(excess*RADIX / half_life):|driftFp| <= |excess*RADIX|sincehalf_life >= 1; then clamped to|driftFp| <= 2^14.t = min(current_target, MAX/4) <= 2^61 - 1;t / RADIX <= 2^45 - 1.delta = (t/RADIX) * driftFp:|delta| < 2^45 * 2^14 = 2^59— cannot overflow.t + delta < 2^61 + 2^59 < 2^63— cannot overflow.
So the only reachable abort is the
excesschain for an absurdtarget_time, and it is reported rather than mis-predicted.Note
V2-only DAA.
current_targetmust itself be a valid script number (1..MAX_SHA256D_TARGET): the covenant compares it with CScriptNum ops, so a target wider than 8 bytes cannot run through any DAA on chain — for any algorithm.
- pyrxd.glyph.compute_next_target_linear(current_target, last_time, current_time, target_time)¶
LEGACY LWMA target — the unity-gain single-sample retarget of pre-resync contracts.
Off-chain mirror of
pyrxd.glyph.dmint.builders._build_linear_daa_legacy()(with theOP_0 OP_MAXtimeDelta floor) AND of_build_linear_daa_legacy_prefloor()(without it) for everycurrent_time >= last_time. On a negative delta the two variants differ; this mirror gives the floored variant’s answer, andbuild_dmint_mint_txdoes not build a backwards mint of a pre-floor contract. This is the LWMA bytecode pyrxd emitted before 2026-09-16 (upstream replaced it with LWMA-v2 on 2026-06-20,c90e6506); the mainnet LWMA deploydea3beb9…carries the pre-floor variant and is mined with this formula. New deploys usecompute_next_target_linear_v2().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.The two operations the bytecode can abort on — the OP_SUB of the times and the
OP_4 OP_MULoftarget_time— go through_script_int64(), so an input the contract cannot evaluate raisesValidationErrorinstead of returning a target.Note
V2-only DAA.
- pyrxd.glyph.compute_next_target_linear_legacy(current_target, last_time, current_time, target_time)[source]¶
LEGACY LWMA target — the unity-gain single-sample retarget of pre-resync contracts.
Off-chain mirror of
pyrxd.glyph.dmint.builders._build_linear_daa_legacy()(with theOP_0 OP_MAXtimeDelta floor) AND of_build_linear_daa_legacy_prefloor()(without it) for everycurrent_time >= last_time. On a negative delta the two variants differ; this mirror gives the floored variant’s answer, andbuild_dmint_mint_txdoes not build a backwards mint of a pre-floor contract. This is the LWMA bytecode pyrxd emitted before 2026-09-16 (upstream replaced it with LWMA-v2 on 2026-06-20,c90e6506); the mainnet LWMA deploydea3beb9…carries the pre-floor variant and is mined with this formula. New deploys usecompute_next_target_linear_v2().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.The two operations the bytecode can abort on — the OP_SUB of the times and the
OP_4 OP_MULoftarget_time— go through_script_int64(), so an input the contract cannot evaluate raisesValidationErrorinstead of returning a target.Note
V2-only DAA.
- pyrxd.glyph.compute_next_target_linear_v2(current_target, last_time, current_time, target_time)[source]¶
LWMA-v2 next target — the damped fractional retarget of CURRENT LWMA deploys.
Off-chain mirror of
pyrxd.glyph.dmint.builders._build_linear_daa_v2()(canonical PhotonicbuildLinearDaaBytecodeatbecf41a) and a port ofcomputeLwmaV2Targetinpackages/lib/src/dmintDaaV2.ts: exactlycompute_next_target_asert_v2()with the responsiveness gain auto-set totarget_time—driftFp = (excess * RADIX) / target_time— so a block twice the target time hits the ±25% clamp. Same int64 proof (target_time >= 1is deploy-enforced, so the OP_DIV never divides by zero).The old unity-gain LWMA (
compute_next_target_linear_legacy()) could jump difficulty 4× in one block and slam the target to 1 on a zero-delta block; this caps each step at ±25% and is stable on-target.Note
V2-only DAA.
- pyrxd.glyph.detect_contract_daa_bytecode(contract_script)[source]¶
Which ASERT/LWMA retarget-formula generation a V2 dMint contract script bakes.
Parses the state (for the declared
daa_modeand the state/code boundary) and classifies the code section withpyrxd.glyph.dmint.builders.detect_daa_bytecode(). For ASERT the result also carries thehalf_liferead out of the bytecode — the value a miner MUST use.- Raises:
ValidationError – not a V2 contract script, or its DAA mode is not ASERT/LWMA (FIXED/EPOCH/SCHEDULE have a single generation;
build_dmint_mint_txbyte-verifies those by rebuilding Part B from the deploy parameters).UnrecognizedDaaBytecodeError – the fragment matches no known generation.
- Parameters:
contract_script (bytes)
- Return type:
- pyrxd.glyph.difficulty_to_target(difficulty, algo=DmintAlgo.SHA256D)[source]¶
Convert difficulty to PoW target:
MAX_SHA256D_TARGET // difficultyfor everyalgo.algois accepted for backward compatibility and does not change the result — seetarget_for_difficulty(), the single definition this andDmintDeployParams.initial_targetshare. BLAKE3/K12 once got(2**256 - 1) // difficultyhere, a target no dMint covenant can read.
- 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). A pyrxd ≤0.24.0 claim, whoseattrs.nameis the qualified name, IS returned, withnameverbatim — seeWaveAttrs.
- 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, name, binding_source, anchor, walk, height_reports, operators=None)[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,walkandheight_reports, so each degrade path is reachable in a test without a chain.nameis the name that was ASKED about (alice.rxdoralice); the glyph’s own mint payload must claim it.height_reportsis oneHeightReportper endpoint; at least two distinct sources must agree on the mark’s height (which must equalanchor.height) and on the height of every walked step. A step whose agreed height is unknown cannot be placed relative to the mark, so the verdict degrades unless it is already known to fall after it.Source labels are compared by source key.
operatorsmaps a label (a URL) to the operator declared for it — the CLI passes its config’s declarations (pyrxd.cli.config.Config.declared_operators()). It is the only way a declaration reaches this judge: nothing declared elsewhere in the process is seen.Nonemeans none declared.- Parameters:
ref (str)
name (str)
binding_source (str)
anchor (MarkAnchor)
walk (MutableChainWalk)
height_reports (Sequence[HeightReport])
- Return type:
- pyrxd.glyph.mark_anchor_dict(anchor, verification=None, *, verified_by=None)[source]¶
The display shape of a
MarkAnchor.caveatandheight_is_verifiedare carried, never dropped: without a VERIFIED verification the height is one endpoint’s claim — at most checked against that endpoint’s own header. A consumer that shows the number and not the caveat has published the unqualified sentence this module exists to prevent.verification is an optional
BlockVerificationfor this anchor’s block, applied bywith_block_verification()(see there); without one,block_verificationandverified_confirmationsareNoneand nothing else changes.
- 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) – The contract’s PoW algorithm. Only SHA256D can be mined: BLAKE3 and K12 raise
NotImplementedErroron BOTH paths, before any hashing. (This used to be ignored on the external-miner path, whose protocol carries no algorithm field and whose answer is re-verified with SHA256d — so a BLAKE3/K12 caller got a SHA256d grind, not a refusal.)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.
NotImplementedError –
algois BLAKE3 or K12 (either path).
- 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, algo=DmintAlgo.SHA256D)[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.algo (DmintAlgo) – The contract’s PoW algorithm. The protocol carries no algorithm field and the answer is re-verified with SHA256d, so anything but SHA256D is refused before the miner is spawned. Pass the contract’s own
state.algo; the default keeps older callers working.
- Raises:
ValidationError – The miner returned a malformed JSON response, a nonce of wrong width, or a nonce that fails local verification.
NotImplementedError –
algois BLAKE3 or K12.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.
- pyrxd.glyph.proven_depth(verification, *, height, min_confirmations)[source]¶
The depth verification PROVED for the block at height, or
Nonewhen it proved none.THE ONE PREDICATE for “this block is verified, deep enough”. Non-
Noneexactly when verification is VERIFIED, carries a claim, is about height, and proved a depth (verified_depth) that reaches min_confirmations.with_block_verification()(the display dict: theblockcheck) andwith_proven_depth()(the anchor a form-2 name judgement reads) both decide through it, so the two cannot disagree about whether the mark’s block was proved deep enough.
- pyrxd.glyph.require_mineable_last_time(last_time, daa_mode, *, stage)[source]¶
Refuse a
last_timethat would make the deployed contract unmineable.The V2 state pushes
lastTimeas a fixed04 <4B LE>. ASERT and LWMA open their retarget withOP_TXLOCKTIME OP_2 OP_PICK OP_SUB, so the FIRST mint builds aCScriptNumfrom whatever the deploy put there — andSCRIPT_VERIFY_MINIMALDATAis in radiant-core’sMANDATORY_SCRIPT_VERIFY_FLAGS, i.e. CONSENSUS, not mempool policy. A non-minimal 4-byte value (anything below2**23, and the default 0 above all) aborts that script: the contract can never be mined, and nothing can fix it once the reveal is confirmed. The 2**23 boundary was measured against a real radiant-core node by the 2026-09-21 review (8388608accepted,8388607rejected withmandatory-script-verify-flag-failed); thelastTime = 0half is re-proved on every regtest run by the control intests/test_dmint_v2_regtest_e2e.py.A value with bit 31 set (above
0x7FFFFFFF) is refused too, although its encoding can be minimal: the top bit of a script number is its sign, so the covenant’s first retarget would measure from a NEGATIVE lastTime while pyrxd’s miner, which parses the state unsigned, refuses to mine such a state rather than predict a target the covenant does not compute (seepyrxd.glyph.dmint.types.is_readable_last_time()). pyrxd does not deploy a contract its own miner would refuse.This is enforced on the DEPLOY path rather than in
DmintDeployParams, because that type is the argument to pyrxd’s byte-level mirror of PhotonicdMintScript— which accepts anylastTime— and the mirror has to stay able to reproduce contracts that already exist on chain. Every shipped caller ofbuild_dmint_contract_scriptmust cross this guard:tests/test_reachability_shipped_callers.pyderives the set of those callers from the source and fails if any one of them does not also call this function. (It asserts that universality, not a count of callers.)
- async pyrxd.glyph.resolve_mark_anchor(*, txid, fetch_verbose, source, min_confirmations, tip_height=None, fetch_header=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.fetch_header(ElectrumXClient.get_block_header-shaped) BINDS the derived height to the block the endpoint says holds the transaction: the header at that height must hash to the verbose reply’sblockhash. Without it the height istip - confirmations + 1alone, which is one block LOW whenever an endpoint’s index trails its node — measured on both shipped servers at once, so a second endpoint agreeing does not catch it. With it, heights up toMAX_INDEX_LAG_BLOCKSabove the formula are tried, and a height no header confirms is never returned: that raises instead. The CLI always passes it.- 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"), for LOOKUPS.Names with no domain (e.g.
"alice") default to domain"rxd". Names with multiple dots use the LAST dot as the domain separator (so"foo.bar.rxd"is("foo.bar", "rxd")) — which is NOT how Photonic splits (it takes the first dot), so this is not used to BUILD a claim:build_wave_metadata()andGlyphBuilder.prepare_wave_reveal()useparse_wave_name(), which refuses any name that is not<label>.rxd. Here, a dotted label reaches the indexer as-is and is refused there.
- pyrxd.glyph.target_to_difficulty(target, algo=DmintAlgo.SHA256D)[source]¶
Convert PoW target to difficulty (approximate):
MAX_SHA256D_TARGET // target.The inverse of
difficulty_to_target(), and like it independent ofalgo(accepted for backward compatibility only).
- 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, operators=None)[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.operatorsmaps a source label (a URL) to its declared operator, as the caller’s config declares it; it is the only way a declaration reaches this comparison.- 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). Likeextract_wave_attrs(), it returns a pyrxd ≤0.24.0 claim’s qualifiedattrs.nameverbatim.- Parameters:
metadata (GlyphMetadata)
- Return type:
WaveAttrs | None
- pyrxd.glyph.wave_registration_fee_for(cbor, *, pay_registration_fee=True, registration_treasury=None)[source]¶
The registration fee a transaction carrying
cborpays, orNonewhen it pays none.Every
GlyphBuilderreveal method that can register a name, the reveal-fee estimator inpyrxd.glyph.feesandpyrxd glyph mint-nftderive the fee here.Nonewhencborregisters no name (wave_registered_label()) or whenpay_registration_fee=False.- Parameters:
pay_registration_fee (bool) –
Trueunless the caller explicitly opts out. WithFalsethe name is registered WITHOUT paying the fee. Three facts to weigh first. The published WAVE protocol and Photonic expect the fee on every registration. RXinDexer does not check it at registration, so the name still registers and resolves: its claim path (wave_index.py:706-807atca8a6a4e) never reads the outputs. And renewing the name takes a transaction that spends its claim token and pays the name’s price to the treasury (wave_index.py:581-642; WAVE-ProtocolANNOUNCEMENT.md:52).Falseis also the way to recover a commit whose name was taken before it could be revealed: the reveal is then a duplicate claim the indexer does not register, and the commit’s value comes back as an NFT carrier plus change. Must be abool: a truthy string such as"False"would otherwise pay, and0would opt out without saying so.registration_treasury (str | None) – a P2PKH address to pay instead of
WAVE_TREASURY_ADDRESS. The published treasury is a MAINNET address and no testnet or regtest treasury is published, so on those chains name one here or passpay_registration_fee=False. Refused together withpay_registration_fee=False, which would ignore it.
- Return type:
WaveRegistrationFee | None
- pyrxd.glyph.wave_registration_price(name)[source]¶
Registration price in photons for
name(a bare label, or the label plus.rxd).Tiered by the length of the label, as Photonic’s
calculateNameCostand RXinDexer’swave_name_priceare (see the module docstring):label
photons
RXD
1-3
10,000,000,000
100
4
5,000,000,000
50
5
1,000,000,000
10
6-63
500,000,000
5
Any label RXinDexer registers has a price, including the 1-2 character labels and the upper-case ones pyrxd itself will not write: the indexer registers those (
_indexer_name_problem()), and both sources price “3 or fewer” at 100 RXD, so a 1-2 character label costs 100 RXD, never less. A name RXinDexer refuses (a.in the label, over 63 characters, a leading-) has no price and raisesValidationError.
- pyrxd.glyph.with_block_verification(shape, verification, *, verified_by=None)[source]¶
shape (a
mark_anchor_dict()) with a block verification’s outcome applied — a copy.THE ONLY PLACE
height_is_verifiedBECOMES TRUE. It isTrueexactly when verification is VERIFIED, is about the height shape reports, and proved a depth that reaches the floor shape reports; the caveat is then the verifier’s own claim sentence (what was proved, and what was not). Otherwiseheight_is_verifiedisFalseand the caveat stays the anchor’s own endpoint’s-word caveat — except when the merkle branch was checked and passed but the height was still not verified: then it isINCLUSION_ONLY_CAVEAT, becauseBOUND_CAVEAT’s “pyrxd checks no … merkle inclusion” would be false. Inclusion alone never makes the height verified.block_verificationcarries the whole outcome (everyBlockVerificationfield) plussource, the endpoint whose data was checked (verified_by);Nonewhen verification is. When VERIFIED,blockhashis the hash of the block proved, which is not necessarily the one the endpoint named (seenamed_blockhash).header_boundIS NOT REWRITTEN, because it is not about the block proved. It says the endpoint’s height was checked against the endpoint’s own header, and that header is the block the endpoint NAMED:block_verification.named_blockhashwhen that is set (the proof served a different block at that height), otherwiseblockhash. Soheader_bound: truebeside ablockhashnever asserts that the endpoint’s single header was that block’s unlessnamed_blockhashis null.TWO DEPTHS, NAMED APART.
confirmationsis always the ENDPOINT’S figure, verified or not.verified_confirmationsis the depth the verifier proved (itsverified_depth: a lower bound, the mark’s block counting as 1) when VERIFIED, andNoneotherwise. When VERIFIED, the floor is judged against the PROVED depth —provisionalanddeep_enoughfollow it, not the endpoint’s number, which may be larger or smaller and is only a claim; VERIFIED requires the proved depth to reachmin_confirmations, so a VERIFIED anchor is never provisional. Otherwise both stay the endpoint’s word, asmark_anchor_dictset them.
- pyrxd.glyph.with_proven_depth(anchor, verification)[source]¶
anchor with the depth verification proved (
proven_depth()) — a copy.For a caller that judges the anchor’s depth (
provisional,usable_for_point_in_time) after verifying its block: a VERIFIED block is judged by the depth proved, anything else by the endpoint’s figure, exactly as the display dict’s are (with_block_verification()).THE CAVEAT FOLLOWS THE SAME RULE, because a form-2 verdict that degrades hands the anchor’s caveat on as its own: it is the verifier’s claim when the depth was proved, and
INCLUSION_ONLY_CAVEATwhen the merkle branch passed and the height still did not verify — the caveatwith_block_verification()gives the display dict — so neither says “pyrxd checks no … merkle inclusion” beside a block line saying it did (#806). Nothing else changes: the height, the endpoint’sconfirmationsandsourceare still the endpoint’s report, andheight_is_verifiedstays False (it is a property of the display dict).- Parameters:
anchor (MarkAnchor)
verification (Any)
- Return type: