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: object

A 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-nft sources 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 reason FtUtxo checks 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 for 1_000_000.5, OverflowError for a negative, and for True a nonsense “budget 1 photons” in the funding error.

  • private_keypyrxd.keys.PrivateKey that unlocks it

Raises:

ValidationErrorvalue is not a positive int.

__init__(txid, vout, value, private_key)
Parameters:
Return type:

None

txid: str
vout: int
value: int
private_key: Any
class pyrxd.glyph.AirdropReceipt[source]

Bases: object

What a broadcast airdrop actually did — the multi-recipient TransferReceipt.

Carries recipients in output order as well as total, 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
__init__(*, txid, ref, recipients, total, fee)[source]
Parameters:
Return type:

None

class pyrxd.glyph.AirdropRecipient[source]

Bases: object

One 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.

__init__(pkh, amount)
Parameters:
Return type:

None

pkh: Hex20
amount: int
exception pyrxd.glyph.BroadcastEchoMismatch[source]

Bases: RxdSdkError

The server’s txid did not match the transaction we signed.

Deliberately NOT a ValidationError. Those are raised before anything is SENT — transfer_nft’s is raised after signing but before broadcast — whereas this one can only happen after the broadcast, when the transaction may well have relayed. A caller with except ValidationError: retry would re-broadcast a transfer that already moved tokens.

Carries local_txid so the caller can check the chain for what was actually sent.

__init__(local_txid, echoed)[source]
Parameters:
Return type:

None

class pyrxd.glyph.ChainStep[source]

Bases: object

One transaction in the singleton’s own history.

__init__(txid, mut_vout, kind, attrs=<factory>, reason='')
Parameters:
Return type:

None

reason: str = ''

Set only for unreadable.

txid: str
mut_vout: int

The mutable output this transaction produced, which the next step must spend.

kind: str

"mint" (a full payload), "update" (partial), or "unreadable".

attrs: dict

The envelope’s attrs, raw. Empty for a step carrying no envelope.

class pyrxd.glyph.ContainerChildRevealScripts[source]

Bases: object

Scripts 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)
Parameters:
Return type:

None

ref: GlyphRef
nft_script: bytes
container_script: bytes
scriptsig_suffix: bytes
container_ref: GlyphRef
class pyrxd.glyph.ContainerRevealScripts[source]

Bases: object

Scripts for a CONTAINER reveal.

locking_script is the plain 63-byte NFT singleton — a container has no distinct script shape (see GlyphBuilder.prepare_container_reveal()).

__init__(ref, locking_script, scriptsig_suffix, child_ref=None)
Parameters:
Return type:

None

child_ref: GlyphRef | None = None
ref: GlyphRef
locking_script: bytes
scriptsig_suffix: bytes
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: object

The dmint object 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 DmintPayload type in types.ts.

__init__(algo, num_contracts, max_height, reward, premine, diff, daa_mode=DaaMode.FIXED, target_block_time=60, half_life=0, window_size=0)
Parameters:
Return type:

None

daa_mode: DaaMode = 0
classmethod from_cbor_dict(d)[source]

Parse the dmint CBOR value from an on-chain payload.

Parameters:

d (dict)

Return type:

DmintCborPayload

half_life: int = 0
target_block_time: int = 60
to_cbor_dict()[source]

Encode to the dict that becomes the dmint CBOR value.

Return type:

dict

window_size: int = 0
algo: DmintAlgo
num_contracts: int
max_height: int
reward: int
premine: int
diff: int
class pyrxd.glyph.DmintDeployParams[source]

Bases: object

Parameters for deploying a V2 dMint contract.

__init__(contract_ref, token_ref, max_height, reward, difficulty, algo=DmintAlgo.SHA256D, daa_mode=DaaMode.FIXED, target_time=60, half_life=3600, height=0, last_time=0, epoch_length=2016, max_adjustment_log2=2, schedule=())
Parameters:
Return type:

None

algo: DmintAlgo = 0
daa_mode: DaaMode = 0
epoch_length: int = 2016
half_life: int = 3600
height: int = 0
property initial_target: int

Compute initial target from difficulty using the SHA256d formula.

last_time: int = 0
max_adjustment_log2: int = 2
schedule: tuple[tuple[int, int], ...] = ()
target_time: int = 60
contract_ref: GlyphRef
token_ref: GlyphRef
max_height: int
reward: int
difficulty: int
class pyrxd.glyph.DmintMineResult[source]

Bases: object

The 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)
Parameters:
Return type:

None

nonce: bytes
attempts: int
elapsed_s: float
class pyrxd.glyph.DmintMintResult[source]

Bases: object

Output of build_dmint_mint_tx().

Parameters:
  • tx – Unsigned transaction (caller must sign).

  • updated_state – New DmintState written into the contract output (height incremented, target updated if DAA is active).

  • contract_script – New contract output script (state + separator + code).

  • reward_script – P2PKH locking script of the miner reward output.

  • fee – Transaction fee in photons.

Note

The transaction returned here is unsigned — it uses raw script bytes for the contract input’s unlocking script (nonce + preimage halves) built by build_mint_scriptsig(). The contract script is a covenant, not a P2PKH, so standard Transaction.sign() is not appropriate. The caller must either set the unlocking script directly or use a custom signing path. See docstring of build_dmint_mint_tx() for details.

__init__(tx, updated_state, contract_script, reward_script, fee)
Parameters:
Return type:

None

tx: Any
updated_state: Any
contract_script: bytes
reward_script: bytes
fee: int
class pyrxd.glyph.DmintState[source]

Bases: object

Parsed 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_v1 is True iff this state was parsed from V1 layout — in which case target_time and last_time are not meaningful on-chain values and are set to 0; daa_mode is always FIXED for 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)
Parameters:
Return type:

None

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 ValidationError if 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:

DmintState

property is_exhausted: bool
is_v1: bool = False
height: int
contract_ref: GlyphRef
token_ref: GlyphRef
max_height: int
reward: int
algo: DmintAlgo
daa_mode: DaaMode
target_time: int
last_time: int
target: int
class pyrxd.glyph.DmintV1ContractInitialState[source]

Bases: object

Just-deployed state of a V1 dMint contract template.

Carries exactly the parameters needed to reconstruct the initial (height=0) contract codescript for every contract of a given deploy. Used by find_dmint_contract_utxos()’s fast path, where the caller already knows the deploy params.

Parameters:
  • num_contracts – Count of parallel contracts the deploy created (1..255 for V1; mainnet GLYPH used 32).

  • reward_sats – Photons emitted per successful mint (must fit in 3 bytes — V1 protocol constant).

  • max_height – Maximum mints per contract (3-byte ceiling).

  • target – 8-byte SHA256d PoW target.

  • algo – PoW algorithm. Defaults to DmintAlgo.SHA256D, which is the only algorithm seen on V1 mainnet.

__init__(num_contracts, reward_sats, max_height, target, algo=DmintAlgo.SHA256D)
Parameters:
Return type:

None

algo: DmintAlgo = 0
num_contracts: int
reward_sats: int
max_height: int
target: int
class pyrxd.glyph.DmintV1DeployParams[source]

Bases: object

Parameters 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_contracts parallel 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.md for the byte-by-byte chain shape this dataclass drives. Live mainnet example: Radiant Glyph Protocol (GLYPH) at commit a443d9df…878b → reveal b965b32d…9dd6.

Parameters:
  • metadataGlyphMetadata for the token. Must include protocol [GlyphProtocol.FT, GlyphProtocol.DMINT] ([1, 4]) and NOT include a v version field (V2 uses v; 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 own MAX_STANDARD_TX_SIZE is 20_000_000 bytes (Radiant-Core/src/policy/policy.h:69 @ v3.1.2) and is never even consulted, since fRequireStandard is hardcoded false (src/validation.cpp:271, src/init.cpp:1995 @ v3.1.2). What actually bounds this is fee: every contract output costs ~241 bytes × the 10_000 photons/byte relay floor.

  • max_height – Maximum mints per contract (3-byte ceiling).

  • reward_photons – Photons paid per successful mint (3-byte ceiling — see V1 contract state layout).

  • difficulty – Initial PoW difficulty (1 = easiest). Translated to 8-byte target via difficulty_to_target().

  • premine_amount – Photons emitted as an additional FT output on the reveal tx (1 photon = 1 FT unit), on top of the mineable supply. None = no premine. The photons are real: the deployer must fund them, and they are NOT deducted from reward_photons * max_height * num_contracts — total issued supply becomes reward_photons * max_height * num_contracts + premine_amount. Mirrors Photonic Wallet RevealDmintParams.premine (mint.ts createRevealOutputs), which likewise appends one ftScript output after the contract outputs.

  • premine_pkh – 20-byte PKH that receives the premine output. None (default) sends it to owner_pkh, which is what Photonic does (it uses the single creator address for both). Only meaningful when premine_amount is 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)
Parameters:
Return type:

None

algo: DmintAlgo = 0
op_return_msg: bytes | None = None
premine_amount: int | None = None
premine_pkh: Hex20 | None = None
metadata: GlyphMetadata
owner_pkh: Hex20
num_contracts: int
max_height: int
reward_photons: int
difficulty: int
class pyrxd.glyph.DmintV1DeployResult[source]

Bases: object

Output 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_script fields; instead it carries placeholder_contract_scripts (one per parallel contract) for fee estimation before the commit txid is known.

Parameters:
  • commit_resultCommitResult — 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 None for 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 / tokenRef txid component. Use the length for fee estimation.

  • max_height – Echoed from params for build_reveal_outputs access.

  • reward_photons – Echoed from params.

  • difficulty – Echoed from params.

  • algo – Echoed from params.

  • op_return_msg – Echoed from params.

  • premine_pkh – Echoed from params; None means the premine (if any) goes to owner_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:
  • commit_result (CommitResult)

  • cbor_bytes (bytes)

  • owner_pkh (Hex20)

  • premine_amount (int | None)

  • num_contracts (int)

  • placeholder_contract_scripts (tuple[bytes, ...])

  • max_height (int)

  • reward_photons (int)

  • difficulty (int)

  • algo (DmintAlgo)

  • op_return_msg (bytes | None)

  • premine_pkh (Hex20 | None)

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 DmintV1RevealScripts for the ordering rule

The method name is build_reveal_outputs (not build_reveal_scripts as 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:

DmintV1RevealScripts ready to be placed into the reveal tx’s outputs.

Return type:

DmintV1RevealScripts

premine_pkh: Hex20 | None = None
commit_result: CommitResult
cbor_bytes: bytes
owner_pkh: Hex20
premine_amount: int | None
num_contracts: int
placeholder_contract_scripts: tuple[bytes, ...]
max_height: int
reward_photons: int
difficulty: int
algo: DmintAlgo
op_return_msg: bytes | None
class pyrxd.glyph.DmintV1RevealScripts[source]

Bases: object

Output 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 createRevealOutputs emits (mint.ts: the premine > 0 ftScript push comes directly after the numContracts dMintScript pushes). Nothing in consensus reads the ordering — the reveal runs only the commit hashlock, whose OP_REFTYPE_OUTPUT check 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 tokenRef with OP_PUSHINPUTREF (0xd0, refType NORMAL). The commit hashlock the reveal spends asserts OP_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 with contractRef[i] = (commit_txid, i+1) and tokenRef = (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 gly magic bytes push.

  • scriptsig_suffix – The push sequence <gly> <CBOR> ready to append after <sig> <pubkey> for vin[0]. Mirrors the FtDeployRevealScripts.scriptsig_suffix convention.

  • 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 (None if no premine). Set it as that output’s value verbatim.

  • op_return_script – Locking script for an optional OP_RETURN data carrier (None if no OP_RETURN).

__init__(contract_scripts, contract_value, cbor_bytes, scriptsig_suffix, premine_script, premine_amount, op_return_script)
Parameters:
  • contract_scripts (tuple[bytes, ...])

  • contract_value (int)

  • cbor_bytes (bytes)

  • scriptsig_suffix (bytes)

  • premine_script (bytes | None)

  • premine_amount (int | None)

  • op_return_script (bytes | None)

Return type:

None

contract_scripts: tuple[bytes, ...]
contract_value: int
cbor_bytes: bytes
scriptsig_suffix: bytes
premine_script: bytes | None
premine_amount: int | None
op_return_script: bytes | None
class pyrxd.glyph.DmintV2DeployParams[source]

Bases: object

Parameters for a V2 dMint token deploy (2-tx: commit + reveal).

Mirrors DmintV1DeployParams. V2 emits num_contracts parallel 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 DaaMode values are supported — FIXED, ASERT, LWMA, EPOCH, and SCHEDULE — and the redesigned covenant advances target/last_time on-chain, byte-matched to canonical Photonic dMintScript (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_deploy deploys V2 by default as of 0.9.0 (allow_v2_deploy defaults to True and is retained only for backward-compatibility).

Parameters:
  • metadataGlyphMetadata (must include GlyphProtocol.FT and GlyphProtocol.DMINT; set version=2 so indexers classify it as V2).

  • owner_pkh – 20-byte PKH of the key that signs commit + the ref-seed reveal inputs.

  • num_contracts – Count of parallel V2 contract UTXOs ([1, 250]).

  • max_height – Maximum mints per contract.

  • reward_photons – Photons paid per successful mint.

  • difficulty – Initial PoW difficulty (1 = easiest).

  • premine_amount – Photons emitted as an extra FT output on the reveal, on top of the mineable supply (mirrors V1 — see DmintV1DeployParams). If metadata carries a dmint.premine field, the two must agree or the deploy is refused.

  • premine_pkh – PKH receiving the premine; None = owner_pkh.

  • op_return_msg – Optional OP_RETURN data carrier (raw bytes after 0x6a).

  • algo – PoW algorithm (default SHA256d; only SHA256D is mined).

  • daa_mode – Must be DaaMode.FIXED (the only mintable mode).

  • target_time – Echoed into the state (DAA-only; vestigial for FIXED).

  • half_life – Echoed into the code (DAA-only; vestigial for FIXED).

__init__(metadata, owner_pkh, num_contracts, max_height, reward_photons, difficulty, premine_amount=None, op_return_msg=None, algo=DmintAlgo.SHA256D, daa_mode=DaaMode.FIXED, target_time=60, half_life=3600, epoch_length=2016, max_adjustment_log2=2, schedule=(), premine_pkh=None)
Parameters:
Return type:

None

algo: DmintAlgo = 0
daa_mode: DaaMode = 0
epoch_length: int = 2016
half_life: int = 3600
max_adjustment_log2: int = 2
op_return_msg: bytes | None = None
premine_amount: int | None = None
premine_pkh: Hex20 | None = None
schedule: tuple[tuple[int, int], ...] = ()
target_time: int = 60
metadata: GlyphMetadata
owner_pkh: Hex20
num_contracts: int
max_height: int
reward_photons: int
difficulty: int
class pyrxd.glyph.DmintV2DeployResult[source]

Bases: object

Output of GlyphBuilder.prepare_dmint_deploy() for V2 deploys.

Mirrors DmintV1DeployResult: V2 emits num_contracts parallel 1-photon singleton contract UTXOs directly in the reveal (no separate deploy tx, no reward pool). Call build_reveal_outputs() once the commit confirms to get the reveal-tx output scripts.

Parameters:
  • commit_resultCommitResult — 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 None for no premine (mirrors V1).

  • num_contracts – Count of parallel V2 contracts.

  • placeholder_contract_scripts – Tuple of N V2 contract scripts built with the placeholder commit txid (00…00) — same byte length as the final scripts, for fee estimation before the commit txid is known.

:param max_height, reward_photons, difficulty, algo, op_return_msg, daa_mode,

target_time, half_life: Echoed from params for build_reveal_outputs().

__init__(commit_result, cbor_bytes, owner_pkh, premine_amount, num_contracts, placeholder_contract_scripts, max_height, reward_photons, difficulty, algo, op_return_msg, daa_mode, target_time, half_life, epoch_length=2016, max_adjustment_log2=2, schedule=(), premine_pkh=None)
Parameters:
Return type:

None

build_reveal_outputs(commit_txid)[source]

Build reveal-tx output scripts given the confirmed commit txid.

Mirrors DmintV1DeployResult.build_reveal_outputs(): emits num_contracts parallel 1-photon V2 contract UTXOs (contractRef[i] = commit:(i+1), tokenRef = commit:0) + the gly/CBOR reveal scriptSig suffix + optional premine FT output + optional OP_RETURN. The returned DmintV1RevealScripts bag has the same shape — and the same output-ordering rule — for V1 and V2.

Parameters:

commit_txid (str)

Return type:

DmintV1RevealScripts

epoch_length: int = 2016
max_adjustment_log2: int = 2
premine_pkh: Hex20 | None = None
schedule: tuple[tuple[int, int], ...] = ()
commit_result: CommitResult
cbor_bytes: bytes
owner_pkh: Hex20
premine_amount: int | None
num_contracts: int
placeholder_contract_scripts: tuple[bytes, ...]
max_height: int
reward_photons: int
difficulty: int
algo: DmintAlgo
op_return_msg: bytes | None
daa_mode: DaaMode
target_time: int
half_life: int
class pyrxd.glyph.FoldedRecord[source]

Bases: object

A mutable glyph’s attrs as of some point in its chain.

__init__(attrs, steps_applied, through_txid, incomplete, reason='')
Parameters:
Return type:

None

reason: str = ''
attrs: dict
steps_applied: int

How many chain steps were folded to produce it.

through_txid: str

The last step included. Empty if only the mint was.

incomplete: bool

True when a step in the folded range could not be read. The result is then a fold of what WAS readable, which is not the same thing as the record - callers must degrade.

class pyrxd.glyph.FtAirdropBuild[source]

Bases: object

A signed, un-broadcast FT airdrop — the multi-recipient form of FtTransferBuild.

Parameters:
  • tx – the signed Transaction

  • fee – photons paid, sourced from plain RXD rather than from the token

  • ref – the token distributed

  • recipients – destinations in output order, so recipients[i] describes vout i. Callers reconcile a broadcast against this, and an unordered collection would make that reconciliation guesswork.

__init__(tx, fee, ref, recipients)
Parameters:
Return type:

None

serialize()[source]

Raw transaction BYTES, ready for await client.broadcast(...).

Return type:

bytes

property total: int

Units leaving the wallet across every recipient output.

tx: Transaction
fee: int
ref: GlyphRef
recipients: tuple[AirdropRecipient, ...]
class pyrxd.glyph.FtAirdropParams[source]

Bases: object

Parameters for a multi-recipient FT airdrop.

Mirrors FtTransferParams, with amount + new_owner_pkh replaced by an ordered list of AirdropRecipient.

Parameters:
  • ref – the GlyphRef identifying the token

  • utxos – list of FtUtxo available to spend

  • recipients – ordered destinations. Output order follows this list.

  • private_key – sender’s pyrxd.keys.PrivateKey

  • funding – plain-RXD AirdropFunding inputs 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_royaltyNone (default) pays iff royalty.enforced; True pays an advisory royalty anyway; False never pays.

  • allow_overpay – accept a fee_rate above the overpay ceiling, forwarded to build_airdrop_tx(). Same omission, and the same reason it matters, as FtTransferParams — 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:
Return type:

None

allow_below_relay_floor: bool = False
allow_overpay: bool = False
change_pkh: Hex20 | None = None
dust_limit: int = 546
fee_rate: int = 10000
pay_royalty: bool | None = None
royalty: GlyphRoyalty | None = None
sale_price: int = 0
ref: GlyphRef
utxos: list[FtUtxo]
recipients: list[AirdropRecipient]
private_key: Any
funding: list[AirdropFunding]
class pyrxd.glyph.FtAirdropResult[source]

Bases: object

Output of FtUtxoSet.build_airdrop_tx().

Parameters:
  • tx – signed Transaction, ready to broadcast

  • recipient_scripts – FT locking scripts, index-aligned with the recipients argument and with tx.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 None when the airdrop consumed the selected inputs exactly.

  • rxd_change_photons – photons returned as a plain P2PKH change output, or 0 when 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 GlyphRef

  • fee – fee paid in photons. This is the actual fee — value_in - value_out — which can exceed size * fee_rate when 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>)
Parameters:
Return type:

None

tx: Any
recipient_scripts: tuple[bytes, ...]
change_ft_script: bytes | None
rxd_change_photons: int
royalty_payouts: tuple[RoyaltyPayout, ...]
ref: GlyphRef
fee: int
recipients: tuple[AirdropRecipient, ...]
class pyrxd.glyph.FtTransferBuild[source]

Bases: object

A signed, un-broadcast FT transfer.

Parameters:
  • tx – the signed Transaction

  • fee – 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’ value

  • to_pkh – recipient’s 20-byte public-key hash

__init__(tx, fee, ref, amount, to_pkh)
Parameters:
Return type:

None

serialize()[source]

Raw transaction BYTES, ready for await client.broadcast(...).

Annotated -> str and documented as “hex” until 2026-08-15, which was wrong on both counts: Transaction.serialize() returns bytes and ElectrumXClient.broadcast() takes them. Runtime was always correct; the contract was not, and it was the same mistaken belief that made assert_fee_matches_size() halve every size it judged. CI’s mypy scope is src/pyrxd/security/ only, so nothing checked this annotation.

Return type:

bytes

tx: Transaction
fee: int
ref: GlyphRef
amount: int
to_pkh: Hex20
class pyrxd.glyph.FtTransferParams[source]

Bases: object

Parameters for an FT transfer transaction.

Parameters:
  • ref – the GlyphRef identifying the token

  • utxos – list of FtUtxo available to spend

  • amount – FT units to send to new_owner_pkh

  • new_owner_pkh – recipient’s 20-byte PKH

  • private_key – sender’s pyrxd.keys.PrivateKey

  • funding – plain-RXD AirdropFunding inputs 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_rate above the overpay ceiling (MAX_FEE_OVERPAY_MULTIPLE x the relay floor), forwarded to build_transfer_tx(). This dataclass had no such field, so the ceiling was unreachable through this API: fee_rate=100_001 raised with no way through, while the identical build via FtUtxoSet.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 royalty here, unlike FtAirdropParams. FtTransferResult has nowhere to report who was paid, and paying a royalty without reporting it would be worse than not offering the option. Use FtAirdropParams with 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)
Parameters:
Return type:

None

allow_below_relay_floor: bool = False
allow_overpay: bool = False
change_pkh: Hex20 | None = None
dust_limit: int = 546
fee_rate: int = 10000
ref: GlyphRef
utxos: list[FtUtxo]
amount: int
new_owner_pkh: Hex20
private_key: Any
funding: list[AirdropFunding]
class pyrxd.glyph.FtTransferResult[source]

Bases: object

Output of FtUtxoSet.build_transfer_tx().

Parameters:
  • tx – signed Transaction, ready to broadcast

  • new_ft_script – locking script of the transfer (recipient) output

  • change_ft_script – locking script of the change output, or None if the transfer was an exact match

  • ref – the FT’s GlyphRef

  • fee – fee paid in photons

Note

No royalty_payouts here, unlike FtAirdropResult. Paying a royalty without reporting who was paid would be worse than not offering it, so FtUtxoSet.build_transfer_tx() takes no royalty argument at all. Use FtUtxoSet.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)
Parameters:
Return type:

None

tx: Any
new_ft_script: bytes
change_ft_script: bytes | None
ref: GlyphRef
fee: int
class pyrxd.glyph.FtUtxo[source]

Bases: object

A single UTXO holding some quantity of one FT.

value and ft_amount are 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 / _OUTPUTS push sumAmount / SATOSHIRadiant-Core/src/script/interpreter.cpp:2196 and :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: an FtUtxo with value != ft_amount describes 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 an amount=250 request; the first “fix” was an if value == ft_amount: raise guard inside the builder, which left the fund loss reachable at value == ft_amount ± 1. A guard inside one caller only protects that caller. Refusing at construction means no FtUtxo anywhere 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() sums ft_amount and FtUtxoSet.select() ranks and covers by it, so a wrong ft_amount used 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 the u.ft_amount reading sites keep working.

  • ft_script – full FT locking script (75 bytes, see pyrxd.glyph.script.build_ft_locking_script())

Raises:

ValidationErrorvalue or ft_amount is not a non-negative int, or the two differ.

__init__(txid, vout, value, ft_amount, ft_script)
Parameters:
Return type:

None

classmethod from_output(*, txid, vout, value, ft_script)[source]

An FtUtxo read straight off a chain output.

The preferred constructor: the token quantity is taken from the output’s value rather than supplied a second time, so the two cannot disagree.

Parameters:
Return type:

FtUtxo

txid: str
vout: int
value: int
ft_amount: int
ft_script: bytes
class pyrxd.glyph.FtUtxoSet[source]

Bases: object

Manages 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.

__init__(ref, utxos)[source]
Parameters:
Return type:

None

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” is select(); and the arithmetic is the same ft_in - out == change identity as a single transfer, with out now sum(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 but amount would 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-RXD funding inputs instead, the same way transfer-nft sources 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 — GetDustThreshold returns 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_limit here governs only the plain-RXD change output — a remainder below it is folded into the fee instead of being emitted, matching pyrxd.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.fee reports 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.PrivateKey owning every selected FT input (single-key, as build_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 iff royalty.enforced; True pays an advisory royalty anyway; False never 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:

FtAirdropResult.

Return type:

FtAirdropResult

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: amount units of this token to one PKH.

A single-recipient build_airdrop_tx(), and deliberately nothing more. The recipient output’s value is amount and the change output’s value is ft_in - amount, because on Radiant an FT’s quantity is its output’s satoshis — 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 from amount. 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 an if value == ft_amount: raise tripwire; re-running at value == ft_amount ± 1 still 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 funding inputs, exactly as transfer-nft sources a separate input to move a dust-carrying singleton. A call with no funding therefore 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_pkh

  • new_owner_pkh (Hex20) – recipient’s 20-byte PKH

  • private_key (Any) – pyrxd.keys.PrivateKey owning the inputs

  • 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 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_rate above the overpay ceiling. The deliberate, greppable opt-out, mirroring allow_below_relay_floor at 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:
  • ValidationErrornew_owner_pkh is not 20 bytes, or a selected UTXO has value != ft_amount (the fail-closed backstop — see build_airdrop_tx()).

  • ValueErroramount <= 0; total FT < amount; fee_rate below the relay floor; or funding cannot cover the fee.

Returns:

FtTransferResult (signed tx, scripts, fee, ref).

Return type:

FtTransferResult

select(amount)[source]

Greedily select the minimum number of UTXOs covering amount.

Strategy: sort by ft_amount descending, take until covered.

Raises:

ValueErroramount exceeds total() (including the empty-set case, where total == 0).

Parameters:

amount (int)

Return type:

list[FtUtxo]

total()[source]

Return the sum of ft_amount across all UTXOs in the set.

Return type:

int

class pyrxd.glyph.GlyphBuilder[source]

Bases: object

Build unsigned Glyph transactions.

Separate commit and reveal methods — caller is responsible for:

  1. Signing the commit tx and broadcasting it.

  2. Waiting for confirmation.

  3. Passing the confirmed commit txid to the reveal method.

  4. Signing the reveal tx (via Transaction + PrivateKey).

Method selection guide (N9 — surface grew to 12 methods across 5 protocols)

Minting (commit → reveal)

Goal

Protocol tag(s)

Reveal method

Mint a singleton NFT

[NFT]

prepare_reveal()

Mint a plain FT

[FT]

prepare_ft_deploy_reveal()

Mint a dMint FT

[FT, DMINT]

prepare_dmint_deploy() (3 txs)

Mint a mutable NFT

[NFT, MUT]

prepare_mutable_reveal()

Mint a collection

``[NFT,CONTAINER]`

prepare_container_reveal()

Mint into a collection

[NFT] + in

prepare_container_child_reveal()

Mint a WAVE name

[NFT,MUT,WAVE]

prepare_wave_reveal()

For every token type the first step is the same: call prepare_commit() (which derives the commit script from the metadata protocol list automatically). Only the reveal step differs.

Transfers (no commit needed)

Low-level (rarely called directly)

  • prepare_reveal() — generic reveal; is_nft picks singleton vs FT reftype

  • build_reveal_scripts() — alternate reveal entry that returns scripts, not params

  • build_transfer_locking_script() — bare FT lock without constructing a tx

  • build_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 as build_ft_transfer_tx() delegates to build_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:

FtAirdropResult.

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:

FtAirdropResult

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-recipient FtUtxoSet.build_airdrop_tx(), so the recipient output’s value is params.amount and 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:

FtTransferResult

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.

Parameters:
Return type:

bytes

prepare_authority_gated_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, authority_ref, authority_script)[source]

Prepare scripts for minting an item gated on an issuer’s authority.

Build the reveal with two inputs — the commit outpoint and the authority token’s UTXO — and two token outputs:

output

script

0

item_script

1

authority_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_script STRIPS 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 a Hex20 here 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_REQUIREINPUTREF in 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, in tests/test_authority_regtest_e2e.py — read build_authority_gated_nft_script() before treating “gated” as a durable property of the minted item.

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_RETURN output 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, and verify_burn() is careful about which parts of it a reader may rely on.

Parameters:
Return type:

bytes

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_OUTPUT check is derived from metadata.protocol: NFT (2 in protocol) produces an OP_2/SINGLETON-expecting commit; any other protocol mix (FT, dMint FT, data, etc.) produces an OP_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; see build_commit_locking_script for the fix note.

Parameters:

params (CommitParams)

Return type:

CommitResult

prepare_container_child_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, container_ref, *, container_script)[source]

Prepare scripts for revealing a token into a container.

The child is an ordinary NFT. What makes its membership checkable is the transaction shape: the reveal spends the container’s own NFT UTXO and re-creates it unchanged, so the container ref appears among the reveal’s output-script refs. Photonic’s indexer only honours an in entry that it can find there (filterRels, packages/app/src/electrum/worker/NFT.ts) — a claimed in ref 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

0

nft_script

1

container_script

(plus any change). container_script is the container’s OWN current locking script and is re-emitted VERBATIM, so output 1 is byte-identical to the UTXO being spent by construction rather than by assumption. It previously took a Hex20 and rebuilt the container with build_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_pkh and could be transposed with it.

cbor_bytes MUST already declare the membership — encode the child’s metadata with container_refs=[container_ref]. This method cross-checks it rather than editing the payload, because the payload hash is already committed to on chain by the commit output.

Raises:

ValidationError – the envelope is unparseable, does not include GlyphProtocol.NFT, or its in list does not contain container_ref.

Parameters:
Return type:

ContainerChildRevealScripts

prepare_container_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, child_ref=None)[source]

Prepare scripts for a CONTAINER (collection) reveal.

A container’s locking script is the plain 63-byte NFT singleton of build_nft_locking_script(). Container-ness is carried by the 7 marker in the envelope’s p field, exactly as in Photonic Wallet (packages/lib/src/script.ts has one nftScript and no container variant). That is what makes a container a first-class token: every NFT classifier, the scanner, and build_nft_transfer_tx() handle it unchanged.

Membership points child → parent and lives in the child’s envelope, in the in field (container_refs). Use prepare_container_child_reveal() to mint a member.

Protocol field must include GlyphProtocol.CONTAINER (7).

Parameters:
Return type:

ContainerRevealScripts

The child_ref prefix (removed in 0.15.0)

pyrxd 0.9.0–0.14.0 prefixed the NFT body with OP_PUSHINPUTREF <child_ref> when child_ref was 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_PUSHINPUTREF leaves the ref on the stack and nothing drops it, so the P2PKH tail hashed the ref and OP_EQUALVERIFY failed for every possible scriptSig. Any photons placed on it were unrecoverable.

  • Creating one destroyed the child NFT. OP_PUSHINPUTREFSINGLETON also 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 a 0xd0 push never re-enters inputSingletonRefSet, so it can never be minted again.

Even with the missing OP_DROP repaired, a script-level link to a live NFT is impossible on Radiant for the second reason, and a repaired link would still be droppable by the holder at any transfer. Membership is therefore metadata, here as in Photonic.

prepare_dat_commit(params)[source]

Prepare a DAT (data-storage) commit — a glyph that mints no token.

Same two-transaction shape as prepare_commit(), but the commit carries no OP_REFTYPE_OUTPUT obligation, so the reveal creates no NFT and no FT. What survives is the payload in the reveal’s scriptSig.

Protocol must include GlyphProtocol.DAT (3). Build the reveal with prepare_dat_reveal() — a DAT commit pops an extra "dat" marker and the ordinary reveal scriptSig is one push short of satisfying it.

Parameters:

params (CommitParams)

Return type:

CommitResult

prepare_dat_reveal(cbor_bytes, *, delegate_ref=None)[source]

Prepare a DAT reveal’s scriptSig suffix.

There is no locking_script: a DAT reveal mints nothing, so the caller’s outputs are whatever they want to keep the value on (ordinary P2PKH change). RevealScripts.locking_script is returned empty to say so rather than handing back a token script that would be wrong.

Parameters:
Return type:

RevealScripts

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/by claims.

This is the alternative to prepare_container_child_reveal() for a minting service, and the only write path pyrxd has for by at 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:

  1. Call with authorised_refs only. Spend the container and/or author tokens, with outputs = base_script plus 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_REQUIREINPUTREF is 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_REQUIREINPUTREF requires 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.

  2. 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 is OP_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_ref as CommitParams.delegate_ref, spends one token in the commit, and emits RevealScripts.delegate_burn_script in 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 RelationshipBasis reports 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_pkh and the outputs were rebuilt with build_nft_locking_script(). That is the exact hazard prepare_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)

  • authorised_refs (Sequence[GlyphRef])

  • base_ref (GlyphRef | None)

  • token_count (int)

Raises:

ValidationErrorauthorised_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 → returns DmintV1DeployResult. V1 is the only format on Radiant mainnet today (see GLYPH at a443d9df…878b). Two-tx deploy: commit + reveal (the reveal directly creates params.num_contracts parallel contract UTXOs).

  • DmintV2DeployParams → returns DmintV2DeployResult. V2 is consensus-proven on regtest + mainnet (#219) and now deploys by default (allow_v2_deploy=True). A soft UserWarning is emitted if the caller explicitly passes allow_v2_deploy=False so the historical opt-out path stays observable without blocking.

Parameters:
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:

DmintV1DeployResult | DmintV2DeployResult

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 to premine_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_amount is what vout[0].value must 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, so premine_amount is the supply in whole units.

No dMint-specific logic here. The cbor_bytes already encode whatever protocol markers the caller chose — dMint FT ([1,4]), plain FT ([1]), or any other combination — via GlyphMetadata. pyrxd treats the protocol markers as caller-owned; classification happens at the indexer layer.

Parameters:
  • commit_txid (str)

  • commit_vout (int)

  • commit_value (int)

  • cbor_bytes (bytes)

  • premine_pkh (Hex20)

  • premine_amount (int)

Return type:

FtDeployRevealScripts

prepare_mutable_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh)[source]

Prepare scripts for a MUT (mutable NFT) reveal.

Returns the two output locking scripts the caller must place in the reveal tx:

  • nft_script: 63-byte NFT singleton (token the owner holds), carrying ref = commit_txid:commit_vout

  • contract_script: 174-byte mutable contract UTXO (holds state), carrying mutable_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_bytes must include GlyphProtocol.MUT (5). Use GlyphMetadata(protocol=[GlyphProtocol.NFT, GlyphProtocol.MUT]).

The reveal needs TWO inputs

input

outpoint

0

commit_txid:commit_vout — the commit (reveal scriptSig)

1

commit_txid:(commit_vout + 1) — a plain seed output

The 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_ref into 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 ref for 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 reason bad-txns-inputs-outputs-invalid-transaction-reference-operations. Two independent chain rules forbid it:

  • OP_PUSHINPUTREFSINGLETON files its ref into foundDisallowedSiblingRefs as well as the push-ref set (CScript::GetPushRefs), and validateTransactionReferenceOperations rejects a transaction where two outputs claim the same one. Both the NFT script and the mutable contract lead with 0xd8, 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 + 1 is therefore not a convention — the covenant computes it. With equal refs the contract would look for commit_vout - 1 and 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 / sl operations of build_mutable_scriptsig()) additionally requires the token output to be re-created in Photonic’s nftAuthScript shape — an OP_REQUIREINPUTREF <mutable_ref> <sha256(contract scriptSig)> OP_2DROP state prefix ahead of the singleton. pyrxd has no builder for that shape yet; the working transaction is spelled out in tests/test_mut_wave_regtest_e2e.py.

Parameters:
Return type:

MutableRevealScripts

prepare_reveal(params)[source]

Prepare the reveal transaction scripts.

Returns locking script + scriptSig suffix. Caller must build, sign, and broadcast the actual transaction.

Parameters:

params (RevealParams)

Return type:

RevealScripts

prepare_wave_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, name, allow_confusable=False)[source]

Prepare scripts for a WAVE (on-chain naming) reveal.

WAVE extends MUT with a name field in the CBOR payload. Protocol field must include GlyphProtocol.WAVE (11).

name must be non-empty, printable, at most 255 characters, and must not impersonate Latin text — see pyrxd.glyph.wave.validate_wave_text(), which is the single definition of that rule and is applied here and in build_wave_metadata(). This method is the funnel every WAVE registration crosses, whatever built its CBOR, so the check belongs here rather than only in the metadata helper a caller may not have used. Pass allow_confusable=True to register a look-alike deliberately. The name is validated here but must already be embedded in cbor_bytes by the caller via either attrs["name"] (the Photonic-compatible canonical shape — required for resolution against RXinDexer and other indexers) or top-level name (legacy pyrxd shape, accepted for backwards compatibility but not indexer-visible).

Photonic-compatible CBOR shape (canonical, see Photonic Wallet packages/lib/src/wave.ts):

{
    "p": [2, 5, 11],
    "attrs": {
        "name": "alice.rxd",
        "domain": "rxd",
        "target": "<radiant_address>",
        "target_type": "address"
    }
}

Use build_wave_attrs() (or pyrxd.glyph.wave.build_wave_metadata()) to construct the canonical shape; passing a top-level name field still works but emits a token RXinDexer will not index.

Protocol requirement: [NFT(2), MUT(5), WAVE(11)].

The reveal shape is MUT’s, including its two-input requirement: the commit outpoint plus a seed outpoint at commit_vout + 1 that gives the mutable contract its own singleton ref. See prepare_mutable_reveal() — a WAVE registration built without the seed input is rejected by consensus, as every one built through 0.15.0 was.

Parameters:
Return type:

MutableRevealScripts

class pyrxd.glyph.GlyphClient[source]

Bases: object

Mint and transfer Glyph tokens over one ElectrumX client and one wallet.

Usage:

client = GlyphClient(electrumx, wallet, store=JsonFilePendingStore("~/.pyrxd/pending"))
result = await client.mint_nft(metadata)
receipt = await client.transfer_ft(ref, 250, recipient_pkh)

Transfer-only callers can skip the store:

client = GlyphClient(electrumx, wallet)
receipt = await client.transfer_ft(ref, 250, recipient_pkh)
Parameters:
  • client – an ElectrumX-style client — await broadcast(raw_tx: bytes) -> txid, await get_transaction(txid), await get_utxos(script_hash).

  • wallet – an HdWallet, or anything exposing await collect_spendable(client), privkey_for_address(address) and addresses.

  • store – where a PendingMint lives 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 store is 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 when store is 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 to build_nft_transfer together, 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]
Parameters:
Return type:

None

async airdrop_ft(ref, recipients, *, allow_overpay=False)[source]

Distribute ref to 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:
Return type:

AirdropReceipt

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; calling reveal_timelock after 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 TimelockRevealBuild that has not been through plan_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:
Return type:

FtAirdropBuild

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:

FtTransferBuild

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 through GlyphBuilder.build_nft_transfer_tx().

Parameters:
Return type:

NftTransferBuild

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.

Parameters:
  • name (str)

  • content_type (str)

  • plaintext (bytes)

  • params (TimelockParams)

  • cek (bytes | None)

  • recipients (Sequence[TimelockRecipient])

  • locator (str | None)

Return type:

TimelockMintBuild

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-run does 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.

Parameters:
Return type:

TimelockRevealBuild

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:
Return type:

PendingMint

async commit_nft(metadata, *, owner_pkh=None)[source]

Phase 1 of an NFT mint. See GlyphMinter.commit_nft().

Parameters:
Return type:

PendingMint

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 a TypeError from inside the minter instead of at the call site.

Parameters:
Return type:

MintResult

async mint_nft(metadata, *, owner_pkh=None)[source]

Commit and reveal an NFT singleton. See GlyphMinter.mint_nft().

Parameters:
Return type:

MintResult

async mint_timelocked_nft(*, name, content_type, plaintext, params, persist=None, cek=None, recipients=(), locator=None, owner_pkh=None)[source]

Seal plaintext behind 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 with chunked-aead-v1, the key’s SHA-256 goes on chain as crypto.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 to cek.

KEY CUSTODY HAS TO PRECEDE THE COMMIT, so this method makes you say how. Supply either persist — called with the TimelockMintBuild after the envelope is built and before a single byte is broadcast — or cek, 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, a ConfirmationTimeoutError, 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 to sha256(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-mint never had this problem because it writes its files before broadcasting; the hook is that ordering, for the SDK.

persist may be sync or async, and anything it raises propagates with nothing broadcast. The build it receives carries cek, ciphertext, cek_hash, stub and metadata — and metadata is 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 with recipients cannot reproduce.

See build_timelock_mint() for the rest of the arguments. owner_pkh behaves as it does on mint_nft(), defaulting to the funding key’s own hash.

Raises:

ValidationError – neither persist nor cek was 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 from get_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_height checks only that a non-negative integer came back and get_block_header only 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 past unlock_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 — and pyrxd glyph timelock-reveal prints it beside opens at in the pre-broadcast summary, so the operator can disagree with a number that would otherwise never have been on screen. “Summary”, not “prompt”: under --yes there 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 to plan_timelock_reveal() directly.

Everything the plan is checked for happens in the underlying function; see its docstring. This adds only the clock.

Parameters:
Return type:

TimelockRevealPlan

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_floor must stay None-defaulted, not False — the same trap documented at length on reveal_nft(). None means “inherit the constructor”; False means “the caller re-asserted the floor for this reveal”. A False default here would forward a deliberate override on every ordinary call, so a client built with allow_below_relay_floor=True would commit and then refuse to reveal, stranding the commit and everything funded into it.

Parameters:
Return type:

MintResult

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_floor must stay None-defaulted, not False. The minter reads None as “the caller said nothing, inherit the constructor” and False as “the caller re-asserted the floor for this reveal”. Defaulting to False here forwarded a deliberate override on every ordinary call, so a client built with allow_below_relay_floor=True committed 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:
Return type:

MintResult

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 before unlock_at without allow_early. Both are ValidationError subclasses, so they land with everything else raised pre-broadcast.

Raises:
  • CekCommitmentMismatchsha256(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_early was 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 amount units of ref to to_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:

TransferReceipt

async transfer_nft(ref, to_pkh, *, allow_overpay=False)[source]

Send the NFT singleton ref to to_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: object

Creator 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')
Parameters:
Return type:

None

algo: str = 'ecdsa-secp256k1'
classmethod from_cbor_dict(d)[source]
Parameters:

d (dict)

Return type:

GlyphCreator

sig: str = ''
to_cbor_dict()[source]
Return type:

dict

pubkey: str
class pyrxd.glyph.GlyphFt[source]

Bases: object

A minted or transferable FT Glyph.

__init__(ref, owner_pkh, amount, metadata)
Parameters:
Return type:

None

ref: GlyphRef
owner_pkh: Hex20
amount: int
metadata: GlyphMetadata | None
class pyrxd.glyph.GlyphInspector[source]

Bases: object

Parse raw transaction bytes to find Glyph outputs. Pure — no network access.

classify_glyph_scriptsig(scriptsig)[source]

What kind of gly envelope, if any, does this scriptSig carry?

THE POINT OF THIS METHOD IS THE THIRD ANSWER. extract_reveal_metadata() returns None both 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:

None when no push equals the gly marker — genuinely not a glyph scriptSig. Otherwise a GlyphEnvelope whose kind is "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’s payload_hash must hash the push that extract_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.

Parameters:

scriptsig (bytes)

Return type:

bytes | None

extract_reveal_metadata(scriptsig)[source]

Parse a reveal TX scriptSig to extract CBOR metadata.

scriptSig format: <sig> <pubkey> <"gly"> <CBOR>. Returns None if this is not a reveal scriptSig (or if the CBOR is malformed / unrecognised).

Catches Exception broadly 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 from ValidationError to cbor2.CBORDecodeError to IndexError on truncated input. Returning None is 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_glyphs because a commit has no meaningful ref until 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.CONTAINER in protocol, surfaced by GlyphMetadata.is_container and pyrxd.glyph.wave.classify_glyph_metadata()). GlyphScanner does this join for you.

Parameters:

tx_outputs (list[tuple[int, bytes]])

Return type:

list[GlyphOutput]

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 a gly marker followed by parseable CBOR; None if no input does. Distinct from extract_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:

scriptsigs (list[bytes])

Return type:

tuple[int, GlyphMetadata] | None

parse_mint_scriptsig(scriptsig)[source]

Decode a dMint mint-claim scriptSig into its 4 canonical pushes.

A V1/V2 dMint mint claim spends the contract UTXO with a scriptSig of the form:

V1 (nonce_width=4): <0x04 nonce(4)> <0x20 inputHash(32)> <0x20 outputHash(32)> <OP_0>  → 72 bytes
V2 (nonce_width=8): <0x08 nonce(8)> <0x20 inputHash(32)> <0x20 outputHash(32)> <OP_0>  → 76 bytes

Where:

  • nonce — little-endian PoW nonce found by the miner.

  • inputHashSHA256d(funding_input_locking_script). NOT a preimage half; the on-chain covenant recomputes SHA256(inputHash || outputHash) from these literal pushes.

  • outputHashSHA256d(OP_RETURN_msg_script at vout[2]).

  • OP_0 — the sentinel push the V1/V2 covenant requires.

Verified against mainnet V1 mint 146a4d68…f3c and the V1 mint c9fdcd34…e530.

Returns a dict with nonce_hex, input_hash, output_hash, version_hint ("v1" | "v2" | None), and scriptsig_length — or None if the scriptSig doesn’t match the canonical 4-push shape.

Catches Exception broadly 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.) return None.

Parameters:

scriptsig (bytes)

Return type:

dict | None

class pyrxd.glyph.GlyphMetadata[source]

Bases: object

CBOR payload for a Glyph token.

__init__(protocol, name='', ticker='', description='', token_type='', main=None, attrs=<factory>, loc='', loc_hash='', decimals=0, image_url='', image_ipfs='', image_sha256='', v=None, dmint_params=None, creator=None, royalty=None, policy=None, rights=None, created='', commit_outpoint='', timelock=None, encrypted_main=None, crypto=None, source_cbor=None, container_refs=(), author_refs=())
Parameters:
Return type:

None

author_refs: tuple[GlyphRef, ...] = ()
commit_outpoint: str = ''
container_refs: tuple[GlyphRef, ...] = ()
created: str = ''
creator: GlyphCreator | None = None
crypto: CryptoMetadata | None = None
decimals: int = 0
description: str = ''
dmint_params: DmintCborPayload | None = None
encrypted_main: EncryptionMetadata | None = None
classmethod for_dmint_ft(ticker, name, decimals=0, description='', image_url='', image_ipfs='', image_sha256='', protocol=None, dmint_params=None)[source]

Construct GlyphMetadata for a dMint-marked FT deploy.

Pass dmint_params (a DmintCborPayload) 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=2 automatically when dmint_params is provided.

Parameters:
Return type:

GlyphMetadata

image_ipfs: str = ''
image_sha256: str = ''
image_url: str = ''
property is_container: bool

True when this envelope marks the token itself as a CONTAINER.

Either declaration counts. GlyphProtocol.CONTAINER (7) is the spec’d form and NO mainnet token uses it — all four containers on Radiant mainnet declare type: “container” on an ordinary NFT/MUT protocol set, so a protocol-only test was False for every real container (#578).

Verified on chain: the “BTC” container (reveal 57c4d660…dfb1) decodes to p = (2,) with type = ‘container’.

Both are DECLARATIONS — type is operator CBOR and nothing on chain enforces it, exactly as nothing enforces the protocol array.

loc: str = ''
loc_hash: str = ''
main: GlyphMedia | None = None
name: str = ''
policy: GlyphPolicy | None = None
rights: GlyphRights | None = None
royalty: GlyphRoyalty | None = None
source_cbor: bytes | None = None
ticker: str = ''
timelock: TimelockSpec | None = None
to_cbor_dict()[source]

Build the dict that gets CBOR-encoded (excluding ‘gly’ marker).

Return type:

dict

token_type: str = ''
v: int | None = None
protocol: list[int]
attrs: dict[str, object]

Glyph attrs carry non-strings in the wild (Photonic authority tokens use a boolean revocable and a permissions list). Values are scalars or lists of scalars; _decode_attr_value() flattens anything deeper. Consumers expecting text should str() what they read, as WaveAttrs.from_dict does.

Type:

dict[str, object], not dict[str, str]

class pyrxd.glyph.GlyphMinter[source]

Bases: object

Two-phase Glyph minting over an ElectrumX client and an HD wallet.

commit_* broadcasts the commit and returns a persisted PendingMint; 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 — and mint_nft() / deploy_ft() compose each pair for callers who do not need to.

Usage:

store = JsonFilePendingStore("~/.pyrxd/pending-mints")
minter = GlyphMinter(client, wallet, store)
result = await minter.mint_nft(metadata)

or, resumably:

pending = await minter.commit_nft(metadata)   # persisted, then broadcast
...                                           # crash, reboot, next week
pending = store.load(commit_txid)
result = await minter.reveal_nft(pending)
Parameters:
  • client – an ElectrumX-style client — await broadcast(hex) -> txid and await get_transaction_verbose(txid) -> dict. Duck-typed, matching wait_for_confirmation().

  • wallet – an HdWallet. Exactly two methods are used — await collect_spendable(client) -> [(utxo, address, privkey)] to fund the commit, and privkey_for_address(address) to re-derive the reveal’s signing key. Duck-typed like client, and deliberately NOT hidden behind a coin-source Protocol: HdWallet is 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 — see examples/regtest_quickstart.py, which drives the minter from a single regtest key.

  • store – where the PendingMint is kept between phases. Required.

  • fee_rate – photons per byte for both transactions.

  • allow_below_relay_floor – accept a fee_rate under 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 raising ConfirmationTimeoutError. The PendingMint survives 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]
Parameters:
Return type:

None

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 to prepare_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:
Return type:

PendingMint

async commit_nft(metadata, *, owner_pkh=None)[source]

Broadcast the commit for an NFT singleton mint.

The PendingMint is persisted and read back before the broadcast.

Parameters:
  • metadata (GlyphMetadata) – must carry NFT and 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. CONTAINER is 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:

PendingMint

async deploy_ft(metadata, *, supply, treasury_pkh=None)[source]

commit_ft() then reveal_ft(). See mint_nft().

Parameters:
Return type:

MintResult

async mint_nft(metadata, *, owner_pkh=None)[source]

commit_nft() then reveal_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:
Return type:

MintResult

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 in PendingMint.carrier_value.

Parameters:
Return type:

MintResult

async reveal_nft(pending, *, fee_rate=None, allow_below_relay_floor=None, allow_overpay=False)[source]

Wait for the commit, then broadcast the NFT reveal.

The stored record is re-validated against the commit script before anything is built — see _assert_payload_still_matches(). On success the record is deleted from the store; on failure it is kept so the reveal can be retried.

Parameters:
Return type:

MintResult

property store: PendingStore

The configured PendingStore — resume through it after a crash.

class pyrxd.glyph.GlyphNft[source]

Bases: object

A minted or transferable NFT Glyph.

A CONTAINER (collection) is an ordinary GlyphNft — same 63-byte locking script, same transfer path. Use is_container to tell one apart and container_refs to read which collection(s) this token declares membership in.

__init__(ref, owner_pkh, metadata)
Parameters:
Return type:

None

property author_refs: tuple[GlyphRef, ...]

Author / issuer tokens this token declares (envelope by field).

property container_refs: tuple[GlyphRef, ...]

Containers this token declares membership in (envelope in field).

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).

False when the metadata could not be resolved — absence of evidence. A caller that must distinguish “not a container” from “unknown” should check metadata is None first.

ref: GlyphRef
owner_pkh: Hex20
metadata: GlyphMetadata | None
class pyrxd.glyph.GlyphPolicy[source]

Bases: object

Token behaviour policy flags.

__init__(renderable=None, executable=None, nsfw=None, transferable=None)
Parameters:
  • renderable (bool | None)

  • executable (bool | None)

  • nsfw (bool | None)

  • transferable (bool | None)

Return type:

None

executable: bool | None = None
classmethod from_cbor_dict(d)[source]
Parameters:

d (dict)

Return type:

GlyphPolicy

nsfw: bool | None = None
renderable: bool | None = None
to_cbor_dict()[source]
Return type:

dict

transferable: bool | None = 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: object

36-byte Glyph reference: txid (reversed LE) + vout (4-byte LE).

__init__(txid, vout)
Parameters:
Return type:

None

classmethod from_bytes(data)[source]

Parse 36-byte wire format.

Parameters:

data (bytes)

Return type:

GlyphRef

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 00000004 decodes to 4:

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, use from_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.

Parameters:

contract_hex (str)

Return type:

GlyphRef

to_bytes()[source]

Encode as 36-byte wire format: txid_reversed + vout_le.

Return type:

bytes

txid: Txid
vout: int
class pyrxd.glyph.GlyphRights[source]

Bases: object

Licensing and attribution information.

__init__(license='', terms='', attribution='')
Parameters:
  • license (str)

  • terms (str)

  • attribution (str)

Return type:

None

attribution: str = ''
classmethod from_cbor_dict(d)[source]
Parameters:

d (dict)

Return type:

GlyphRights

license: str = ''
terms: str = ''
to_cbor_dict()[source]
Return type:

dict

class pyrxd.glyph.GlyphRoyalty[source]

Bases: object

On-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>)
Parameters:
Return type:

None

enforced: bool = False
classmethod from_cbor_dict(d)[source]
Parameters:

d (dict)

Return type:

GlyphRoyalty

minimum: int = 0
to_cbor_dict()[source]
Return type:

dict

bps: int
address: str
splits: tuple[tuple[str, int], ...]
class pyrxd.glyph.GlyphScanner[source]

Bases: object

Scan 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. None if 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-reveal needs 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.txid alone is not enough.

Parameters:

ref (GlyphRef)

Return type:

GlyphMetadata | None

async scan_address(address)[source]

Return all Glyph outputs currently owned at address.

Parameters:

address (str) – Base58Check-encoded P2PKH address.

Returns:

Typed Glyph objects. metadata is None when 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:

List[GlyphNft | GlyphFt]

async scan_script_hash(script_hash)[source]

Return all Glyph outputs for script_hash.

Fetches UTXOs, raw transactions, and (where available) reveal transaction metadata, then constructs typed GlyphNft / GlyphFt objects.

Concurrency: UTXO raw-tx fetches and reveal-metadata resolutions both run in parallel via asyncio.gather. Pre-fix (closes ultrareview re-review N17) the reveal-metadata path was inside the per-utxo loop and serialised one round-trip per glyph; for a 100-glyph wallet that meant ~100x the latency of the now- batched version. Metadata is resolved once per distinct ref, so an FT split across many UTXOs costs one resolution, not N.

Parameters:

script_hash (Hex32 | bytes | str)

Return type:

list[GlyphNft | GlyphFt]

class pyrxd.glyph.JsonFilePendingStore[source]

Bases: PendingStore

One 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 by os.open — not chmod’ed afterwards, which would leave a window at the umask’s mercy — fsync it, then os.replace onto 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.

__init__(directory)[source]
Parameters:

directory (str | PathLike[str])

Return type:

None

delete(commit_txid)[source]

Drop the record. Must not raise if it is already gone.

Parameters:

commit_txid (str)

Return type:

None

property directory: Path

Directory holding the records.

list_pending()[source]

Commit txids with a stored record — the resume list after a crash.

Return type:

list[str]

load(commit_txid)[source]

Return the stored record, or raise PendingMintNotFound.

Parameters:

commit_txid (str)

Return type:

PendingMint

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: object

Where a transaction sits in the chain, according to some endpoint.

__init__(txid, height, confirmations, min_confirmations, source, caveat='height reported by the endpoint and NOT verified: pyrxd has no Radiant header, proof-of-work or merkle-inclusion check, so an endpoint that lies about the height moves the point in time this answer is about', height_is_verified=False)
Parameters:
  • txid (str)

  • height (int | None)

  • confirmations (int)

  • min_confirmations (int)

  • source (str)

  • caveat (str)

  • height_is_verified (bool)

Return type:

None

caveat: str = 'height reported by the endpoint and NOT verified: pyrxd has no Radiant header, proof-of-work or merkle-inclusion check, so an endpoint that lies about the height moves the point in time this answer is about'
height_is_verified: bool = False

Always False. There is no Radiant SPV in this codebase; see the module docstring.

property provisional: bool

Below the caller’s bar — real, but shallow enough to be reorged out.

property usable_for_point_in_time: bool

Has a block, and is buried to the depth the caller asked for.

txid: str
height: int | None

None when the endpoint reports no confirmations — an unmined transaction has no block, and form 2 is unavailable for it by construction rather than by policy.

confirmations: int
min_confirmations: int

What the CALLER required. Carried so the verdict can say what bar was applied.

source: str

An opaque tag naming who said this. Compared against the name→glyph binding’s source so one hostile endpoint cannot move both answers.

class pyrxd.glyph.MintResult[source]

Bases: object

A completed mint — both transactions broadcast.

commit_txid

the commit transaction.

Type:

str

reveal_txid

the reveal transaction.

Type:

str

ref

the token’s permanent GlyphRef (the commit outpoint — see PendingMint.ref).

Type:

pyrxd.glyph.types.GlyphRef

reveal_fee

photons the reveal paid, measured on the signed transaction.

Type:

int

carrier_value

photons on the token output.

Type:

int

owner_pkh

the recipient’s 20-byte public-key hash.

Type:

bytes

__init__(commit_txid, reveal_txid, ref, reveal_fee, carrier_value, owner_pkh)
Parameters:
Return type:

None

commit_txid: str
reveal_txid: str
ref: GlyphRef
reveal_fee: int
carrier_value: int
owner_pkh: bytes
class pyrxd.glyph.MutableChainWalk[source]

Bases: object

The result. Read complete before reading anything else.

__init__(ref, steps, tip_txid, tip_vout, tip_proved_unspent, complete, reason='', excluded=())
Parameters:
Return type:

None

excluded: tuple[str, ...] = ()

Candidates that are not in this singleton’s chain. Reported rather than dropped, because “the index gave me transactions that do not belong to this token” is worth seeing.

property has_unreadable_step: bool
reason: str = ''
ref: str
steps: tuple[ChainStep, ...]
tip_txid: str
tip_vout: int
tip_proved_unspent: bool
complete: bool

Every link verified AND the tip proved unspent. False means DEGRADE - the steps below are a prefix of the truth, not the truth, and the caller must not present them as current.

class pyrxd.glyph.MutableRevealScripts[source]

Bases: object

Scripts for a MUT reveal — two inputs and two outputs required.

See GlyphBuilder.prepare_mutable_reveal() for the transaction shape. ref and mutable_ref are 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)
Parameters:
Return type:

None

mutable_ref: GlyphRef | None = None
ref: GlyphRef
nft_script: bytes
contract_script: bytes
scriptsig_suffix: bytes
payload_hash: bytes
class pyrxd.glyph.NftTransferBuild[source]

Bases: object

A signed, un-broadcast NFT transfer.

Parameters:
  • tx – the signed Transaction

  • fee – 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_changeFalse when the whole funding UTXO became the fee. That is an accepted outcome, not a fault — see nft_transfer_funding_bar() — but a caller showing a confirmation prompt should say so.

__init__(tx, fee, ref, to_pkh, from_address, has_change)
Parameters:
Return type:

None

serialize()[source]

Raw transaction bytes, ready for await client.broadcast(...).

Return type:

bytes

tx: Transaction
fee: int
ref: GlyphRef
to_pkh: Hex20
from_address: str
has_change: bool
class pyrxd.glyph.PendingMint[source]

Bases: object

A 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_address against the wallet at reveal time, so persisting this record can never write key material to disk.

cbor_bytes is held as bytes, not a hex string. That is the house convention for binary in memory (see NegotiatedTerms.hashlock in pyrxd.gravity.swap_state), it is the type cbor_bytes hands over, and it is what build_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, in to_dict().

commit_txid

txid of the broadcast commit transaction.

Type:

str

commit_vout

index of the commit (hashlock) output. Always 0 here.

Type:

int

commit_value

photons in the commit output — the reveal’s only input.

Type:

int

commit_script

the commit output’s locking script, re-checked at reveal time.

Type:

bytes

cbor_bytes

the exact payload the reveal scriptSig must push. Losing these makes the commit output permanently unspendable.

Type:

bytes

owner_pkh

recipient of the minted token (may differ from the spender).

Type:

bytes

is_nft

NFT singleton reveal vs FT reveal.

Type:

bool

carrier_value

photons the reveal places on the token output — a dust carrier for an NFT, the whole premined supply for an FT.

Type:

int

fee_rate

photons per byte the reveal will be fee’d at.

Type:

int

funding_address

address whose key signs the reveal and receives its change.

Type:

str

__init__(commit_txid, commit_vout, commit_value, commit_script, cbor_bytes, owner_pkh, is_nft, carrier_value, fee_rate, funding_address)
Parameters:
  • commit_txid (str)

  • commit_vout (int)

  • commit_value (int)

  • commit_script (bytes)

  • cbor_bytes (bytes)

  • owner_pkh (bytes)

  • is_nft (bool)

  • carrier_value (int)

  • fee_rate (int)

  • funding_address (str)

Return type:

None

classmethod from_dict(d)[source]

Rebuild from to_dict(), REJECTING an unrecognised schema_version.

Fail-closed on the version the way SwapRecord.from_dict dispatches 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:

PendingMint

property ref: GlyphRef

the commit outpoint, not the reveal’s.

prepare_reveal embeds this into the reveal’s locking script, and it is what extract_ref_from_{nft,ft}_script reads 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_dict rather than to_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:

dict

commit_txid: str
commit_vout: int
commit_value: int
commit_script: bytes
cbor_bytes: bytes
owner_pkh: bytes
is_nft: bool
carrier_value: int
fee_rate: int
funding_address: str
exception pyrxd.glyph.PendingMintNotFound[source]

Bases: RxdSdkError

No PendingMint is stored under the requested commit txid.

Module-local rather than in pyrxd.security.errors, matching WaveNameNotFound and RxinDexerNotFound.

class pyrxd.glyph.PendingStore[source]

Bases: ABC

Where a PendingMint lives between the commit and the reveal.

Required, not optional — see the module docstring. Two implementations ship: JsonFilePendingStore (use this) and UnsafeNullPendingStore (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 through load() and compares it before broadcasting, so a store that silently drops the write is caught there rather than one crash later.

abstractmethod delete(commit_txid)[source]

Drop the record. Must not raise if it is already gone.

Parameters:

commit_txid (str)

Return type:

None

durable: ClassVar[bool] = True

Whether save() actually persists. GlyphMinter skips its read-back verification (and warns) when this is False; a store that sets it False while claiming to persist defeats that check.

abstractmethod list_pending()[source]

Commit txids with a stored record — the resume list after a crash.

Return type:

list[str]

abstractmethod load(commit_txid)[source]

Return the stored record, or raise PendingMintNotFound.

Parameters:

commit_txid (str)

Return type:

PendingMint

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: object

The 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 — see docs/solutions/runtime-errors/dmint-v1-mint-scriptsig-shape.md for the prior incident that motivated returning all three values from a single helper.

Parameters:
  • preimage – 64-byte SHA256d PoW preimage; feeds mine_solution.

  • input_hashSHA256d(input_script) — push as scriptSig_inputHash.

  • output_hashSHA256d(output_script) — push as scriptSig_outputHash.

__init__(preimage, input_hash, output_hash)
Parameters:
Return type:

None

preimage: bytes
input_hash: bytes
output_hash: bytes
class pyrxd.glyph.RoyaltyPayout[source]

Bases: object

One 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, in royalty_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)
Parameters:
Return type:

None

address: str
pkh: bytes
photons: int
class pyrxd.glyph.RxinDexerClient[source]

Bases: object

Thin wrapper over ElectrumXClient for 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 into RxinDexerError subclasses.

The pyrxd.glyph.wave.WaveResolver is 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.

Parameters:
  • address (str)

  • token_ref (str | None)

Return type:

Any

async glyph_get_metadata(ref)[source]

glyph.get_metadata — decoded CBOR metadata for a token.

Parameters:

ref (str)

Return type:

dict[str, Any] | None

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}.

Parameters:
  • limit (int)

  • cursor (str | None)

  • token_type (int | None)

Return type:

dict[str, Any]

async glyph_get_token(ref)[source]

glyph.get_token — fetch a token by its txid:vout ref.

Parameters:

ref (str)

Return type:

dict[str, Any] | None

async glyph_get_tokens_by_type(token_type, limit=100, cursor=None, order='ref')[source]

glyph.get_tokens_by_type — tokens of one type.

order="recent" = newest-deployed first (v4 index); order="ref" (default) = legacy stable ref-hash order. Cursors must not be reused across a change of order. Returns {"tokens": [...], "next_cursor": str | None}.

Parameters:
  • token_type (int)

  • limit (int)

  • cursor (str | None)

  • order (str)

Return type:

dict[str, Any]

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_vout or 72-hex, per glyph_api.py::_parse_ref): open orders offering that token, newest-index-first, server-side limit clamped to 200. With BOTH base_ref and quote_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.

Parameters:
  • base_ref (str | None)

  • quote_ref (str | None)

  • limit (int)

  • offset (int)

Return type:

Any

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 an available key — {'available': False, 'ref': ..., 'name': ...} for a name that is TAKEN, {'available': False, 'error': ...} for one that fails validate_wave_name, {'available': True, ...} when it is genuinely free. This method did return 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_name runs first and . is not in its WAVE_CHARS. Callers passing "alice.rxd" were answered with an error dict — which the old bool() then reported as available. Stripping to the label is done by pyrxd.glyph.wave.WaveResolver.check_available(); a bare label is what this method expects.

Parameters:

name (str)

Return type:

bool

async wave_get_subdomains(name)[source]

Subdomains of name. Returns empty list if none.

Parameters:

name (str)

Return type:

list[str]

async wave_resolve(name)[source]

Raw wave.resolve call. Returns the indexer’s dict response, or None if the name is not registered. Higher-level callers should usually use pyrxd.glyph.wave.WaveResolver.

Parameters:

name (str)

Return type:

dict[str, Any]

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 against electrumx.radiantcore.org 2026-09-16, confirmed in wave_index.py upstream. 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 as str(dict). Both halves are fixed here.

Entries flagged status == "expired" are dropped: upstream keeps a lapsed name listed so the owner can see it needs renewal, but it no longer RESOLVES, and this method’s contract is names that resolve.

Parameters:

address (str)

Return type:

list[str]

async wave_stats()[source]

Indexer-level WAVE stats — useful for health checks.

Return type:

IndexerStats

exception pyrxd.glyph.RxinDexerError[source]

Bases: Exception

Base class for RXinDexer-specific errors.

exception pyrxd.glyph.RxinDexerNotFound[source]

Bases: RxinDexerError

A lookup returned no result (name not registered, token unknown, etc.).

class pyrxd.glyph.TransferReceipt[source]

Bases: object

What 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
__init__(*, txid, ref, amount, fee, to_pkh)[source]
Parameters:
Return type:

None

class pyrxd.glyph.UnsafeNullPendingStore[source]

Bases: PendingStore

Discards everything. Named Unsafe because 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 PendingStore yet.

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 UserWarning so the choice shows up in logs rather than only in the source.

__init__()[source]
Return type:

None

delete(commit_txid)[source]

Drop the record. Must not raise if it is already gone.

Parameters:

commit_txid (str)

Return type:

None

durable: ClassVar[bool] = False

Whether save() actually persists. GlyphMinter skips its read-back verification (and warns) when this is False; a store that sets it False while claiming to persist defeats that check.

list_pending()[source]

Commit txids with a stored record — the resume list after a crash.

Return type:

list[str]

load(commit_txid)[source]

Return the stored record, or raise PendingMintNotFound.

Parameters:

commit_txid (str)

Return type:

PendingMint

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: UserWarning

Retained 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, mint 1239f64a…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: object

Parsed WAVE attrs dict, mirroring the on-chain Photonic shape.

__init__(name, domain, target, target_type='address', expires=None)
Parameters:
  • name (str)

  • domain (str)

  • target (str)

  • target_type (str)

  • expires (int | None)

Return type:

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.

classmethod from_dict(d)[source]

Parse from a CBOR attrs dict; rejects missing required fields.

Parameters:

d (dict)

Return type:

WaveAttrs

target_type: str = 'address'
to_dict()[source]

Serialize as the CBOR attrs dict.

expires is emitted ONLY when set, so a record that never carried one still mints the exact four-key map it always did - adding a field to this type must not change the bytes pyrxd publishes for callers that never asked for it.

Return type:

dict[str, object]

name: str
domain: str
target: str
class pyrxd.glyph.WaveIdentityVerdict[source]

Bases: object

What can be said about a name and a mark. Read form first.

__init__(form, ref, binding_source, binding_verified, target_at_height, height, provisional, expiry, degraded_reason, caveat)
Parameters:
  • form (int)

  • ref (str)

  • binding_source (str)

  • binding_verified (bool)

  • target_at_height (str | None)

  • height (int | None)

  • provisional (bool)

  • expiry (str)

  • degraded_reason (str)

  • caveat (str)

Return type:

None

property is_point_in_time: bool
form: int

1 = present-tense only (degraded). 2 = established at height. An int, not a bool.

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.

binding_verified: bool

False until something verifies the binding ON CHAIN. Nothing does yet.

target_at_height: str | None
height: int | None
provisional: bool
expiry: str
degraded_reason: str

Empty iff form == 2.

caveat: str
exception pyrxd.glyph.WaveNameNotFound[source]

Bases: WaveResolverError

Raised when the requested name does not exist in the indexer.

class pyrxd.glyph.WaveRecord[source]

Bases: object

A 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='')
Parameters:
Return type:

None

classmethod from_indexer_response(data)[source]

Build a WaveRecord from the JSON-RPC response.

Tolerant of field naming — RXinDexer’s response wraps things in attrs or 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"}

name comes back as the bare label; it is re-qualified here so callers see the same alice.rxd they asked for. claim_txid falls back to the ref’s txid, which IS the registration transaction.

Parameters:

data (dict[str, Any])

Return type:

WaveRecord

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) and txid: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.

status: str = ''

"active", "grace", or absent. A lapsed name does not resolve at all (the indexer returns None), so this is never "expired" here.

Type:

Lifecycle as the indexer reports it

name: str
target: str
target_type: str
claim_txid: str
block_height: int
class pyrxd.glyph.WaveResolver[source]

Bases: object

High-level WAVE name resolver — composes RxinDexerClient.

Accepts either an ElectrumXClient (auto-wraps in RxinDexerClient) or an existing RxinDexerClient. 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 of RxinDexerError) on transport / parse failures. Name-not-found raises WaveNameNotFound so callers can distinguish “does not exist” from “indexer is down”.

__init__(client)[source]
Parameters:

client (ElectrumXClient | RxinDexerClient)

async check_available(name)[source]

Return True if name is not yet registered.

SENDS THE LABEL, NOT THE QUALIFIED NAME — the same rule resolve() documents. resolve was corrected in #695 and this twin was left sending "alice.rxd", which validate_wave_name refuses; 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.

Parameters:

name (str)

Return type:

bool

async resolve(name)[source]

Look up a qualified WAVE name (e.g. "alice.rxd").

Raises WaveNameNotFound if the name is not registered. Raises WaveResolverError on transport / parse failures.

THE INDEXER WANTS THE LABEL, NOT THE QUALIFIED NAME. RXinDexer’s resolve() runs validate_wave_name before anything else, and . is not in its WAVE_CHARS, so "alice.rxd" is answered with {"error": "Invalid character: ."} — measured against the public electrumx.radiantcore.org indexer 2026-09-16 and confirmed in electrumx/server/wave_index.py upstream. This method sent the qualified name, so it never resolved a real name against the canonical indexer. The label is sent now, and an error key in the answer is raised rather than parsed as a record.

Parameters:

name (str)

Return type:

WaveRecord

async reverse_lookup(address)[source]

Return the list of WAVE names that resolve to address.

Parameters:

address (str)

Return type:

list[str]

async stats()[source]

Return indexer-level stats — useful for health checks.

Return type:

dict[str, Any]

exception pyrxd.glyph.WaveResolverError

Bases: RxinDexerError

Raised 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 the 10 marker, 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)

  • permissions (Sequence[str])

  • revocable (bool)

  • description (str)

Raises:

ValidationErrorissuer 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:

GlyphMetadata

pyrxd.glyph.build_burn_proof_script(token_ref, *, amount=None, burn_reason=None)[source]

Build the OP_RETURN burn-proof output script.

Give this output 0 photons: it is unspendable, and any value on it is destroyed along with the token.

Parameters:
  • token_ref (GlyphRef) – the token being burned.

  • amount (int | None) – units burned, for a fungible token. Omitted for an NFT.

  • burn_reason (str | None) – free text recorded in the proof. Operator-supplied and displayed, so treat it as untrusted on read.

Raises:

ValidationErroramount is negative, or the encoded proof exceeds the CBOR cap.

Return type:

bytes

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:

bytes

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 dMintScript for 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:

bytes

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)

height and target use minimal pushes (variable width) so the state script is MINIMALDATA-compliant from height 0 / target MAX onward — the old fixed 04 [LE4] height push was rejected by radiantd’s MINIMALDATA mempool policy on mainnet. lastTime stays a 4-byte push (Unix timestamps are always 4-byte minimal), which simplifies Part C’s 04 || NUM2BIN(4, locktime) reconstruction.

Parameters:

params (DmintDeployParams)

Return type:

bytes

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_NUMEQUALVERIFY at epilogue offset 168 sums photons under this codescript and requires the total to equal the contract’s reward field. Producing a plain P2PKH instead breaks FT conservation and the network rejects the mint.

Raises:

ValidationErrorminer_pkh is not 20 bytes.

Parameters:
Return type:

bytes

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:

  1. The contract input’s outpoint txid + the contract ref (so a nonce mined for one contract slot can’t be replayed against another)

  2. The miner’s funding-input locking script (so the miner cannot substitute a different funding source after finding a nonce)

  3. 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 push

Callers feed preimage to mine_solution() and pass input_hash + output_hash to build_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 Transaction from build_dmint_mint_tx() — vout[2] is required to be the OP_RETURN msg output (mainnet-canonical 4-output shape).

Returns:

PowPreimageResult carrying the preimage and the two script hashes that the scriptSig must push for the covenant to accept the mint.

Raises:

ValidationErrorunsigned_tx has fewer than 4 outputs (no OP_RETURN at vout[2]) OR vout[2] is not actually an OP_RETURN script. Build the tx via build_dmint_mint_tx() with a non-empty op_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:

PowPreimageResult

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 of build_mint_scriptsig()) and the absence of the Photonic-Wallet op_return_msg convention 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 push

Unlike the V1 helper, this function takes output_script as 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 selecting output_script should 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_preimage left 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_v1 MUST be False — 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:

PowPreimageResult with 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:

PowPreimageResult

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_hash and output_hash from the same build_pow_preimage() call that produced the preimage the miner solved. The on-chain covenant recomputes SHA256(input_hash || output_hash) from these pushes and folds that into the PoW hash — diverging them silently produces a mandatory-script-verify-flag-failed rejection after a successful mine.

Parameters:
  • nonce (bytes) – nonce_width-bytes nonce (found during mining).

  • input_hash (bytes) – 32-byte SHA256d(input_script) from PowPreimageResult.

  • output_hash (bytes) – 32-byte SHA256d(output_script) from PowPreimageResult.

  • 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:

bytes

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>

Parameters:
  • mutable_ref (GlyphRef) – The singleton ref that identifies the mutable contract.

  • payload_hash (bytes) – 32-byte SHA256d of the CBOR metadata payload.

Return type:

bytes

pyrxd.glyph.build_mutable_scriptsig(operation, cbor_bytes, contract_output_index, ref_hash_index, ref_index, token_output_index)[source]

Build the scriptSig for spending a mutable NFT contract input.

The mutable NFT script expects the scriptSig stack (bottom→top):

gly_marker | cbor_payload | operation | contract_output_index | ref_hash_index | ref_index | token_output_index

Parameters:
  • operation (Literal['mod', 'sl']) – "mod" (modify — update payload hash) or "sl" (seal — burn the mutable contract).

  • cbor_bytes (bytes) – CBOR-encoded metadata for the new state.

  • contract_output_index (int) – Output index of the mutable contract in the tx.

  • ref_hash_index (int) – Index into the refdatasummary for this token.

  • ref_index (int) – Index of the singleton ref in token output data.

  • token_output_index (int) – Output index of the token in the tx.

Return type:

bytes

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 inputHash and outputHash from 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:
  • txid_le (bytes) – 32-byte txid in little-endian (internal byte order)

  • contract_ref_bytes (bytes) – 36-byte contract ref (wire format)

  • input_script (bytes) – miner’s input locking script (e.g. P2PKH)

  • output_script (bytes) – miner’s output script (e.g. OP_RETURN message)

Returns:

PowPreimageResult with preimage, input_hash, output_hash.

Return type:

PowPreimageResult

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_EQUALVERIFY and 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_template and _glyph_reveal_unlock) and once more into pyrxd.cli.glyph_helpers. Each copy restated the estimated unlocking length; REVEAL_SIG_PREFIX_BYTES is imported here instead, because a copy that drifted low would make the reveal fee guard under-estimate and pass — which strands the commit.

Parameters:
  • private_key (Any)

  • scriptsig_suffix (bytes)

Return type:

Any

pyrxd.glyph.build_wave_metadata(*, qualified_name, target, target_type='address', description='', allow_confusable=False)[source]

Construct a Photonic-compatible WAVE GlyphMetadata.

Parameters:
  • qualified_name (str) – e.g. "alice.rxd" — split into name + domain.

  • target (str) – the address (or other identifier) the name resolves to.

  • target_type (str) – "address" by default; other values are reserved for future schemas (e.g. "cross_chain").

  • description (str) – optional human-readable description; stored as top-level desc in CBOR (NOT inside attrs).

  • allow_confusable (bool)

Return type:

GlyphMetadata

The returned metadata has protocol [NFT, MUT, WAVE] and an attrs dict matching the Photonic on-chain shape — pass it through encode_payload() and then GlyphBuilder.prepare_wave_reveal() to construct the actual reveal transaction.

The top-level name field on GlyphMetadata is intentionally left empty: validation in prepare_wave_reveal prefers attrs.name, and emitting both would create ambiguity if they ever disagree.

pyrxd.glyph.classify_glyph_metadata(metadata)[source]

Return the highest-specificity protocol classification for a metadata payload.

Examples

[NFT, MUT, WAVE]"wave" (when attrs.name present) [NFT, MUT, WAVE] without attrs.name → "mut" (legacy, won’t resolve) [NFT, MUT, CONTAINER]"container" [NFT, MUT]"mut" [NFT, AUTHORITY]"authority" [NFT, ENCRYPTED, TIMELOCK]"timelock" [NFT, ENCRYPTED]"encrypted" [NFT]"nft" [FT, DMINT]"dmint" [FT]"ft" [DAT]"dat"

The string mirrors GlyphOutput.glyph_type values 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:

str

pyrxd.glyph.compute_next_target_asert(current_target, last_time, current_time, target_time, half_life)[source]

Compute next ASERT-lite target (mirrors the redesigned on-chain bytecode).

The redesign replaced OP_LSHIFT/OP_RSHIFT (which Radiant evaluates as a big-endian bit-string shift — wrong on the LE target encoding) with an unrolled 4-step OP_2MUL/OP_2DIV loop with a per-step overflow cap:

drift = trunc((current_time - last_time - target_time) / half_life)  # clamp [-4,+4]
drift > 0:  repeat |drift|x:  target = MAX_TARGET if target > MAX/2 else target*2
drift < 0:  repeat |drift|x:  target = target // 2
minimum target is 1

The per-step cap matches the miner’s newTarget = min(MAX, oldTarget<<drift) clamp-at-MAX semantics (a naive target << drift would overshoot MAX).

Note

V2-only DAA. V1 has no DAA (fixed difficulty).

Parameters:
  • current_target (int)

  • last_time (int)

  • current_time (int)

  • target_time (int)

  • half_life (int)

Return type:

int

pyrxd.glyph.compute_next_target_linear(current_target, last_time, current_time, target_time)[source]

Compute next linear/LWMA target (mirrors the redesigned on-chain bytecode).

Divide-first with caps so the on-chain OP_MUL never overflows int64:

timeDelta_capped = max(0, min(current_time - last_time, 4 * target_time))
target_capped    = min(current_target, MAX_TARGET // 4)
new_target       = min(MAX_TARGET, (target_capped // target_time) * timeDelta_capped)
minimum target is 1

The MAX/4 target cap means LWMA contracts cannot have a difficulty floor below 4 (target <= MAX_TARGET/4). The 0-floor on timeDelta mirrors the on-chain OP_0 OP_MAX (Radiant-Core/Photonic-Wallet#2): a backwards-clock block (locktime earlier than the previous mint) gives a negative delta that would otherwise underflow the on-chain int64 multiply.

Note

V2-only DAA.

Parameters:
  • current_target (int)

  • last_time (int)

  • current_time (int)

  • target_time (int)

Return type:

int

pyrxd.glyph.difficulty_to_target(difficulty, algo=DmintAlgo.SHA256D)[source]

Convert difficulty to PoW target.

Parameters:
Return type:

int

pyrxd.glyph.extract_wave_attrs(cbor_data)[source]

Pull WaveAttrs out of a decoded CBOR payload, if present.

Returns None for non-WAVE payloads or WAVE payloads using only the legacy top-level name shape (those exist on-chain but RXinDexer won’t index them).

Parameters:

cbor_data (dict)

Return type:

WaveAttrs | None

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. One get_utxos call 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 whose tokenRef matches. Slower (3+ extra round-trips) but works on any live token where you only know the token_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’s tx_hash, and its output script byte-equal to the script the server claimed. Defends against a malicious or buggy ElectrumX serving altered bytes (mirrors find_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. None returns all available.

  • min_confirmations (int) – Skip UTXOs younger than this many blocks. Default 1 (require at least 1 confirmation).

Returns:

A list of DmintContractUtxo for 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:

list[DmintContractUtxo]

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’s filterAttrs drops null/undefined before 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 about expires.

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 str because the two readers disagreed on type: a mint arrived already stringified while decode_update_payload returned raw CBOR, so expires was '1849006310' from one and 1849006310 from the other.

They no longer disagree. _decode_attr_value now preserves scalars and scalar lists on the mint side, because the blanket str() was not merely lossy — it INVERTED meaning: an authority token’s revocable: false became the string 'False', which is truthy, so a NON-revocable authority read back as revocable, and permissions: ['mint'] became "['mint']", losing every entry. Measured on the mainnet WAVE chain, both readers now return int for expires.

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_attrs already drops non-string keys, for the collision reason payload.py gives.

Parameters:
  • through_index (int | None) – fold only the first N+1 steps. None folds all of them.

  • walk (MutableChainWalk)

Return type:

FoldedRecord

pyrxd.glyph.judge_name_at_mark(*, ref, binding_source, anchor, walk, step_heights)[source]

Compose an anchor and a completed walk into a form-2 verdict, or degrade to form 1.

Pure: every network answer is already in anchor, walk and step_heights, so each degrade path is reachable in a test without a chain.

step_heights maps each walked txid to its block height. A step whose height is unknown cannot be placed relative to the mark, so the walk cannot be folded “as of” anything and the verdict degrades — an unplaceable step is not a step that happened after.

Parameters:
Return type:

WaveIdentityVerdict

pyrxd.glyph.mark_anchor_dict(anchor)[source]

The display shape of a MarkAnchor.

caveat and height_is_verified are carried, never dropped: the height is one endpoint’s claim and pyrxd has no Radiant header, proof-of-work or merkle check to hold it to. A consumer that shows the number and not the caveat has published the unqualified sentence this module exists to prevent.

Return type:

dict

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 target state 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. See DEFAULT_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 every progress_interval_s while grinding. Feed the pair to live_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:
  • ValidationErrorpreimage is not 64 bytes, target is not positive, nonce_width is not 4 or 8, max_attempts is < 1, or progress_interval_s is not positive.

  • NotImplementedErroralgo is BLAKE3 or K12.

  • MaxAttemptsError – No solution found within max_attempts iterations. The exception’s attempts and elapsed_s attributes carry telemetry.

Return type:

DmintMineResult

Note

There is no “easy” target for this loop. The verifier requires four leading zero bytes, so the mean is 2**96 / target and floors at 2**33 8.6e9 attempts 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 with estimate_attempts() before starting it, and use benchmark_sha256d() (or pyrxd 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() or mine_solution_external() directly. The two paths share semantics — both return a DmintMineResult with a nonce that satisfies the target — but have disjoint parameter sets (max_attempts vs timeout_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): run mine_solution() in this process. Slow but correct. Use for tests, small examples, and contracts where mining takes < a minute.

  • miner_argv is not None: invoke mine_solution_external() with the supplied argv. The external miner (e.g. pyrxd.contrib.miner, a custom binary, or glyph-miner) runs as a subprocess and returns a verified nonce via the JSON-over-stdio protocol. The local re-verification in mine_solution_external is the load-bearing safety check against a buggy or malicious miner.

Parameters:
  • preimage (bytes) – 64-byte preimage from build_pow_preimage().

  • target (int) – The PoW target.

  • nonce_width (Literal[4, 8]) – 4 for V1 contracts, 8 for V2.

  • algo (DmintAlgo) – Hash algorithm. Currently only SHA256D is implemented; BLAKE3 and K12 raise from mine_solution(). Ignored on the external-miner path (the protocol doesn’t carry an algo field; external miners are assumed SHA256D until the protocol is extended).

  • miner_argv (list[str] | None) – None → in-process; otherwise an argv list passed to subprocess.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_s instead).

  • timeout_s (float) – Subprocess timeout on the external-miner path. Ignored in-process (use max_attempts there).

  • 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 to mine_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 as progress=None.

  • progress_interval_s (float) – Minimum seconds between progress calls.

Returns:

DmintMineResult with the verified nonce.

Raises:
  • MaxAttemptsError – in-process exhausted max_attempts, or external miner exceeded timeout_s / explicitly signalled exhaustion.

  • ValidationError – external miner returned a malformed response or a nonce that fails local verification.

Return type:

DmintMineResult

pyrxd.glyph.mine_solution_external(preimage, target, *, miner_argv, nonce_width=4, timeout_s=600.0, progress=None, progress_interval_s=0.5)[source]

Delegate nonce search to an external miner via JSON-over-subprocess.

Spawns miner_argv as 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:

  1. Read one JSON object from stdin: {"preimage_hex", "target_hex", "nonce_width"}.

  2. Search for a valid nonce.

  3. 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 raises MaxAttemptsError immediately rather than waiting for the parent timeout to fire).

  4. 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 $PATH can 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 $PATH resolution.

  • Verify the binary’s checksum against the upstream release before first use.

  • Run pyrxd in an environment where $PATH is 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 MaxAttemptsError raised on expiry.

  • progress (Callable[[int, float], None] | None) – Optional callback(attempts, elapsed_s). When None (the default), behavior is byte-for-byte identical to before this parameter existed: a single blocking subprocess.run call 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 calls progress with the most recently observed one roughly every progress_interval_s — the same cadence contract mine_solution() documents. An external miner that never emits a progress frame (the common case today) simply means progress is never called; the grind still runs to completion or timeout exactly as it would with progress=None. A raising callback propagates and the subprocess is terminated — the supported way to impose a deadline, mirroring mine_solution() and pyrxd.contrib.miner.parallel.mine.

  • progress_interval_s (float) – Minimum seconds between progress calls. Ignored when progress is None.

Raises:
  • ValidationError – The miner returned a malformed JSON response, a nonce of wrong width, or a nonce that fails local verification.

  • MaxAttemptsError – The miner exceeded timeout_s.

  • FileNotFoundErrorminer_argv[0] is not on PATH.

Return type:

DmintMineResult

pyrxd.glyph.parse_mutable_nft_script(script)[source]

Parse a mutable NFT output script, returning (mutable_ref, payload_hash) or None.

Parameters:

script (bytes)

Return type:

tuple[GlyphRef, bytes] | None

async pyrxd.glyph.resolve_mark_anchor(*, txid, fetch_verbose, source, min_confirmations, tip_height=None)[source]

Ask an endpoint where txid is, and return it qualified.

fetch_verbose should be an ElectrumXClient.get_transaction_verbose-shaped call: it binds the echoed txid to the one requested, which is the one thing here that IS checked.

Raises:
  • ValidationError – if min_confirmations is 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:

MarkAnchor

pyrxd.glyph.royalty_due(royalty, sale_price)[source]

Total photons owed on a sale of sale_price photons.

min(max(minimum, floor(sale_price * bps / 10_000)), sale_price).

sale_price is 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. minimum raises the payment toward the sale price; it cannot raise it past.

The cap is the whole difference from Photonic’s calculateRoyalty, and it exists because minimum is otherwise an unbounded number chosen by the token’s creator and spent from the funding inputs of whoever moves the token. GlyphRoyalty only requires minimum >= 0. See the module docstring for the use case this deliberately removes.

Raises:

ValidationErrorsale_price is negative or not an int.

Parameters:
Return type:

int

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.

Parameters:

payouts (tuple[RoyaltyPayout, ...])

Return type:

tuple[tuple[bytes, int], …]

pyrxd.glyph.royalty_payouts(royalty, sale_price)[source]

Resolve royalty at sale_price into 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 splits this is a single payout to royalty.address. With splits the total is divided floor(total * split_bps / bps) per recipient and the residue — flooring loss plus any bps the splits do not cover — goes to royalty.address. Recipients that round to zero photons are dropped.

This is also where royalty addresses are actually validated. GlyphRoyalty only 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_price is invalid.

Parameters:
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:

  1. Build canonical CBOR with sig=”” and the pubkey.

  2. commit_hash = SHA256d(cbor)

  3. message = SHA256(“glyph-v2-creator:” || commit_hash)

  4. 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:

GlyphMetadata

pyrxd.glyph.split_qualified_name(qualified)[source]

Split "alice.rxd" into ("alice", "rxd").

Names with no domain (e.g. "alice") default to domain "rxd" — matching Photonic’s behavior. Names with multiple dots use the LAST dot as the domain separator (so "foo.bar.rxd" is ("foo.bar", "rxd")).

Parameters:

qualified (str)

Return type:

tuple[str, str]

pyrxd.glyph.target_to_difficulty(target, algo=DmintAlgo.SHA256D)[source]

Convert PoW target to difficulty (approximate).

Parameters:
Return type:

int

pyrxd.glyph.verify_authority_claim(authority_ref, verdicts)[source]

Does the item’s by claim on authority_ref stand up?

Deliberately takes VERDICTS rather than metadata. At becf41a Photonic’s verifyAuthorityChain matched the by field 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 own by passed it (reported as M26). The argument for taking verdicts does not depend on that defect: by is an operator assertion; only verify_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.

Parameters:
Return type:

AuthorityVerdict

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.

Parameters:
Return type:

AuthorityVerdict

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=False for a genuine burn — a function called verify_burn answering 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_scripts pushes that ref under 0xd0/0xd8; and some script in spent_output_scripts does.

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 ok verdict. Anyone can write a burn proof about any token; metering supply from ok plus amount takes 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.pubkey signed this metadata.

WHAT A True ESTABLISHES, EXACTLY: the key named in this blob signed this blob. Nothing more. creator.pubkey is 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_signature returns (True, "") — indistinguishable from the original. Demonstrated in tests/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 True that has to stay narrow.

Returns:

(True, “”) if the named key signed this metadata; (False, reason) otherwise. A non-empty reason on True flags a lossy decode — see _cbor_for_verifying().

Parameters:

metadata (GlyphMetadata)

Return type:

tuple[bool, str]

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.

Parameters:
  • nonce_width (Literal[4, 8]) – 4 for V1 contracts, 8 for V2. Default 8 preserves the pre-V1-support behavior. Passed as keyword-only so a stray positional 4 vs 8 is a type error rather than a silent V1/V2 confusion.

  • preimage (bytes)

  • nonce (bytes)

  • target (int)

Return type:

bool

async pyrxd.glyph.walk_mutable_chain(*, mint_txid, candidates, fetch_tx, is_unspent=None, candidate_source='', tip_source='', max_steps=256)[source]

Follow a mutable glyph from mint_txid along its own spend chain.

candidates is 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_tx must 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 reports complete=False, because an unproved tip cannot be distinguished from a truncated history.

Raises:

ValidationError – only for a CONTRADICTION - a step whose mutable output carries a different ref. Absence degrades; contradiction raises. That split follows glyph/dmint/chain.py’s S2 verifier, where a server disagreeing with itself is not a “no result”.

Parameters:
Return type:

MutableChainWalk

pyrxd.glyph.wave_attrs_from_metadata(metadata)[source]

Convenience wrapper: extract WaveAttrs from a parsed GlyphMetadata (typically from GlyphInspector.extract_reveal_metadata()).

Returns None for non-WAVE metadata or legacy-shape WAVE without attrs.name (which RXinDexer cannot index).

Parameters:

metadata (GlyphMetadata)

Return type:

WaveAttrs | None