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_key – pyrxd.keys.PrivateKey that unlocks it

Raises:

ValidationError – value 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.AnchorBindingError[source]

Bases: NetworkError

The endpoint answered, but no header in the search window binds the height to its block.

Not “unreachable”: the endpoint answered the depth and the block hash. A caller reporting this should suggest re-running or asking another server, rather than “check that the server is reachable” — and should say WHICH of three things happened, from the attributes rather than by parsing the message:

  • disagrees — every height in the window was served and none hashes to the block the node named: the endpoint’s index and its node disagree (or it is serving inconsistent data).

  • unserved non-empty — some (or all) of the headers could not be read, so nothing established a disagreement: the matching header may simply be the one that never arrived. “Its index and its node disagree” would be a false sentence here, and was one (0.25.0 review, round 2: an honest chain whose request for the mark’s own header went unanswered).

  • both empty — the endpoint named no block to bind to at all.

served and unserved are the heights, in the order they were tried.

__init__(message, *, served=(), unserved=())[source]
Parameters:
Return type:

None

property disagrees: bool

True only when every height asked was served and none matched.

exception pyrxd.glyph.BroadcastEchoMismatch[source]

Bases: RxdSdkError

The server’s broadcast reply named a txid other than the transaction we sent.

Deliberately NOT a ValidationError, and NOT a NetworkError. Those are what a caller retries on — a ValidationError is raised before anything is sent, a NetworkError reads as “the server could not be reached” — whereas this one can only happen after the broadcast, when the transaction may well have relayed. A caller with except NetworkError: retry that rebuilds would send a SECOND transaction, paying twice if the first one went through.

Carries local_txid (hashed from the bytes that were sent) so the caller can check the chain for what was actually sent, and echoed (the server’s claim, unverified).

Raised by pyrxd.network.electrumx.verified_broadcast_txid(), the one echo check; re-exported as pyrxd.glyph.client.BroadcastEchoMismatch and pyrxd.BroadcastEchoMismatch.

__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='', envelope_cbor=b'')
Parameters:
Return type:

None

envelope_cbor: bytes = b''

The committed envelope CBOR, VERBATIM — the bytes whose sha256d this step’s mutable output commits to. Empty for a step whose record could not be bound or read.

Carried because attrs is not the whole record. A WAVE mint names itself in attrs.name OR app.data.name (RXinDexer reads both), and the second is outside attrs; a consumer that needs to know what the glyph’s OWN payload claims — HashMark §7.6 form 2 checking the name an index bound to it — has to read these bytes, which are already txid-bound (fetched through a hashing fetch) and hash-bound (the covenant commits to them). compare=False: two steps are the same step whatever bytes rode along.

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, *, registration_fee_output)
Parameters:
Return type:

None

ref: GlyphRef
nft_script: bytes
container_script: bytes
scriptsig_suffix: bytes
container_ref: GlyphRef
registration_fee_output: WaveRegistrationFee | None

The WAVE registration fee this reveal must pay, or None. As RevealScripts.registration_fee_output: after both token outputs, before change.

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, *, registration_fee_output)
Parameters:
Return type:

None

child_ref: GlyphRef | None = None
ref: GlyphRef
locking_script: bytes
scriptsig_suffix: bytes
registration_fee_output: WaveRegistrationFee | None

The WAVE registration fee this reveal must pay, or None. As RevealScripts.registration_fee_output: after the token output, before change.

class pyrxd.glyph.DaaBytecodeVersion[source]

Bases: IntEnum

Which GENERATION of ASERT/LWMA retarget bytecode a deployed contract carries.

This is orthogonal to the dMint contract format (V1 vs V2 state layout): every member here is a V2-format contract. The retarget formula baked into Part B changed upstream on 2026-06-19 (ASERT, Photonic ed53cd41) and 2026-06-20 (LWMA, Photonic c90e6506); pyrxd did not follow until 2026-09-16, so contracts deployed by pyrxd in between carry the older formula and MUST keep being mined under it — a covenant’s bytecode is immutable, and a miner that recomputes the next target with the wrong formula produces a state the covenant rejects.

  • V2 — the current canonical fractional fixed-point retarget (_build_asert_daa_v2 / _build_linear_daa_v2). Every new deploy.

  • LEGACY — the integer power-of-2 ASERT stepper (_build_asert_daa_legacy) or the unity-gain LWMA WITH the OP_0 OP_MAX timeDelta floor (_build_linear_daa_legacy); what pyrxd emitted from 2026-06-17 to 2026-09-15.

  • LEGACY_LWMA_PREFLOOR — LWMA only: the unity-gain retarget WITHOUT the timeDelta floor (_build_linear_daa_legacy_prefloor), as pyrxd emitted on 2026-06-16 — the bytecode of the mainnet LWMA deploy dea3beb9….

Detected from a contract’s code section by pyrxd.glyph.dmint.builders.detect_daa_bytecode(); a contract matching no known template is REPORTED (UnrecognizedDaaBytecodeError), never guessed.

__new__(value)
LEGACY_LWMA_PREFLOOR = 0
LEGACY = 1
V2 = 2
class pyrxd.glyph.DaaMode[source]

Bases: IntEnum

__new__(value)
FIXED = 0
EPOCH = 1
ASERT = 2
LWMA = 3
SCHEDULE = 4
class pyrxd.glyph.DmintAlgo[source]

Bases: IntEnum

__new__(value)
SHA256D = 0
BLAKE3 = 1
K12 = 2
class pyrxd.glyph.DmintCborPayload[source]

Bases: 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’s DmintPayload type (packages/lib/src/types.ts at becf41a):

daa?: { mode, targetBlockTime,
        halfLife?, asymptote?,        // ASERT
        windowSize?,                  // LWMA
        epochLength?, maxAdjustment?, // EPOCH
        schedule?: { height, difficulty }[] }  // SCHEDULE

Every optional key is emitted ONLY when set (non-zero / non-empty), so a payload that does not use one is byte-identical to what pyrxd emitted before the key existed (2026-09-16: asymptote, epochLength, maxAdjustment, schedule were added; FIXED/ASERT/LWMA payloads that do not set them are unchanged). schedule entries are (height, difficulty) — difficulty, not target, is what the payload type declares.

max_adjustment is stored as the RAW payload number and is informational only: Photonic’s builder reads a payload value of 1..4 as the log2 shift count and 8/16 as multipliers (script.ts maxAdjustmentToLog2; its Mint UI passes the same number to the payload and the builder), while pyrxd’s deploy-dmint --max-adjustment is a multiplier baked as DmintDeployParams.max_adjustment_log2. So the payload value alone does not say which clamp a contract bakes — only the contract bytecode does. Validation accepts exactly the values Photonic’s builder accepts (EPOCH_MAX_ADJUSTMENT_PAYLOAD_VALUES); pyrxd does not reinterpret them.

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

None

asymptote: int = 0
daa_mode: DaaMode = 0
epoch_length: int = 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
max_adjustment: int = 0
schedule: tuple[tuple[int, int], ...] = ()
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=240, 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 = 240
height: int = 0
property initial_target: int

The deploy target for difficulty — target_for_difficulty(), for every algo.

BLAKE3 and K12 used to get MAX_V2_TARGET_256 // difficulty here: a target wider than the 8 bytes Part B2 can read, so the contract could never be minted. algo selects the hash opcode (and the algoId the state records); it does not change the target 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). On the final mint no output carries a state: this is the spent state with only height advanced to max_height (so is_exhausted is True).

  • contract_script – The script of output 0: the recreated contract (state + separator + code), or on the final mint the burn d8 <contractRef> 6a that replaces it.

  • reward_script – The FT-wrapped reward output script (P2PKH, then bd d0 <tokenRef> and the 12-byte FT code), paid to the miner.

  • fee – Transaction fee in photons.

  • is_final_mint – True when this mint takes the contract to max_height: output 0 is the burn, not a recreated contract, and no further mint of this contract is possible.

Note

The transaction returned here is unsigned — it uses raw script bytes for the contract input’s unlocking script (nonce + preimage halves) built by build_mint_scriptsig(). The contract script is a covenant, not a P2PKH, so 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, is_final_mint=False)
Parameters:
Return type:

None

is_final_mint: bool = False
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
property next_mint_is_final: bool

True when the next mint takes this contract to max_height.

That mint recreates no contract: the covenant’s final branch requires the burn d8 <contractRef> 6a in its place (see build_dmint_contract_burn_script()), and build_dmint_mint_tx() builds exactly that.

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.

  • max_height – Maximum mints per contract.

  • target – The PoW target (MAX_SHA256D_TARGET // difficulty). The codescript is rebuilt with today’s builder, which pushes it minimally; a contract pyrxd deployed before 2026-09-23 at difficulty 256 or more carries a non-minimal 8-byte push instead, so this fast path does not find it (it can never be minted anyway).

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

__init__(num_contracts, reward_sats, max_height, target, algo=DmintAlgo.SHA256D)
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:
  • metadata – GlyphMetadata 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, [1, 2**31]. A V1 contract’s height is a 4-byte field that every mint but the last rewrites as NUM2BIN(height + 1, 4) (epilogue 54 78 54 80 7e), which cannot encode 2**31, so with a larger max_height the contract stops at height 2**31 - 1 with mints left (MAX_V1_MAX_HEIGHT; check_dmint_v1_bounds() has this and the next two bounds). Mainnet has such V1 deploys ($BRO: 696,969,000,000); pyrxd mints them up to that height but does not deploy one.

  • reward_photons – Photons paid per successful mint, [1, RADIANT_MAX_PHOTONS].

  • difficulty – Initial PoW difficulty (1 = easiest; at most MAX_SHA256D_TARGET). The target is MAX_SHA256D_TARGET // difficulty (difficulty_to_target()), pushed as a minimal script number.

  • premine_amount – Photons emitted as an additional FT output on the reveal tx (1 photon = 1 FT unit), on top of the mineable supply. None = no premine. The photons are real: the deployer must fund them, and they are NOT deducted 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_result – CommitResult — commit-tx script + fee. Same shape as the V2 result’s field.

  • cbor_bytes – Encoded CBOR token body.

  • owner_pkh – 20-byte PKH of the deploy key.

  • premine_amount – Photons for the optional premine output, or 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:
  • metadata – GlyphMetadata (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 ([1, 2**63 - 1]: the covenant reads it as a script number). The upper bounds on this and the other numeric parameters, and the reason for each, are in pyrxd.glyph.dmint.types.check_v2_numeric_bounds().

  • reward_photons – Photons paid per successful mint ([1, RADIANT_MAX_PHOTONS]).

  • difficulty – Initial PoW difficulty (1 = easiest; at most MAX_SHA256D_TARGET). The target is MAX_SHA256D_TARGET // difficulty for every algo.

  • 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). BLAKE3 and K12 deploy with the same 8-byte target formula as SHA256d, as Photonic does. pyrxd can deploy them but cannot mine them: every miner it runs or speaks to (including the external-miner protocol behind claim-dmint --miner-cmd) grinds and verifies SHA256d only, and claim-dmint refuses a BLAKE3/K12 contract before any grind. Minting one needs a miner for that hash, which pyrxd does not ship.

  • daa_mode – Any DaaMode (FIXED, ASERT, LWMA, EPOCH, SCHEDULE).

  • target_time – Echoed into the state (DAA-only; vestigial for FIXED). At most MAX_V2_TARGET_TIME (0xFFFFFFFF s) for ASERT, LWMA and EPOCH, whose retarget reads it; FIXED and SCHEDULE never read it as a number and take up to 2**63 - 1.

  • half_life – Baked into the ASERT-v2 bytecode (ASERT only; vestigial otherwise). Defaults to the canonical Photonic DEFAULT_ASERT_HALFLIFE (240 s) so an omitted value deploys what a Photonic miner assumes; before 2026-09-16 the default was 3600.

  • last_time – Unix timestamp written into the deployed state’s lastTime slot — the baseline the FIRST mint’s retarget measures against. None (the default) stamps the build time, which is what Photonic’s dMintScript call site passes (Math.floor(Date.now() / 1000)). For ASERT and LWMA this value is read as a script number on the first mint, so a non-minimal 4-byte encoding (anything below 2**23, including the old implicit 0) makes the contract unmineable from birth — such a value is refused here rather than deployed. A value above 0x7FFFFFFF is refused too: bit 31 is the script-number sign, so the covenant would read it as negative (see require_mineable_last_time()).

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

None

algo: DmintAlgo = 0
daa_mode: DaaMode = 0
epoch_length: int = 2016
half_life: int = 240
last_time: int | None = None
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_result – CommitResult — commit-tx script + fee.

  • cbor_bytes – Encoded CBOR token body.

  • owner_pkh – 20-byte PKH of the deploy key.

  • premine_amount – Photons for the optional premine output, or 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().

Parameters:

last_time – The RESOLVED deploy timestamp (params.last_time, or the build time when that was None). Resolved once in prepare_dmint_deploy and carried here so the placeholder scripts and the real reveal scripts agree.

__init__(commit_result, cbor_bytes, owner_pkh, premine_amount, num_contracts, placeholder_contract_scripts, max_height, reward_photons, difficulty, algo, op_return_msg, daa_mode, target_time, half_life, last_time, epoch_length=2016, max_adjustment_log2=2, schedule=(), premine_pkh=None)
Parameters:
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
last_time: 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_royalty – None (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 / SATOSHI — Radiant-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:

ValidationError – value 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:
  • ValidationError – new_owner_pkh is not 20 bytes, or a selected UTXO has value != ft_amount (the fail-closed backstop — see build_airdrop_tx()).

  • ValueError – amount <= 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:

ValueError – amount 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, *, pay_registration_fee=True, registration_treasury=None)[source]

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

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

output

script

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.

If cbor_bytes is marked WAVE and registers a name, the result carries its registration fee (pay_registration_fee, registration_treasury: as prepare_mutable_reveal()), paid after both token outputs.

Raises:

ValidationError – the envelope is unparseable or does not include GlyphProtocol.NFT.

Parameters:
  • commit_txid (str)

  • commit_vout (int)

  • cbor_bytes (bytes)

  • owner_pkh (Hex20)

  • authority_ref (GlyphRef)

  • authority_script (bytes)

  • pay_registration_fee (bool)

  • registration_treasury (str | None)

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.

A WAVE claim outside the rule pyrxd writes claims by is refused HERE, before the commit exists: once a commit is broadcast it can only be spent by revealing exactly the CBOR it commits to, so a refusal at reveal time would strand its value. There is no override on this path (see refuse_unregistrable_wave_claim()).

Nothing for a WAVE claim’s registration fee goes in the commit. The reveal pays it, from a second, plain wallet input (Photonic’s shape; see prepare_wave_reveal()), so the fee stays in the wallet until the reveal that registers the name spends it. wave_registration_fee_for(result.cbor_bytes) gives the amount before anything is broadcast.

Parameters:

params (CommitParams)

Return type:

CommitResult

prepare_container_child_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, container_ref, *, container_script, pay_registration_fee=True, registration_treasury=None)[source]

Prepare scripts for revealing a token into a container.

The child is an ordinary NFT. What makes its membership checkable is the transaction shape: the reveal spends the container’s own NFT UTXO and re-creates it unchanged, so the container ref appears among the reveal’s output-script refs. Photonic’s indexer only honours an 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.

If cbor_bytes is marked WAVE and registers a name, the result carries its registration fee (pay_registration_fee, registration_treasury: as prepare_mutable_reveal()), paid after both token outputs.

Raises:

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

Parameters:
  • commit_txid (str)

  • commit_vout (int)

  • cbor_bytes (bytes)

  • owner_pkh (Hex20)

  • container_ref (GlyphRef)

  • container_script (bytes)

  • pay_registration_fee (bool)

  • registration_treasury (str | None)

Return type:

ContainerChildRevealScripts

prepare_container_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, child_ref=None, *, pay_registration_fee=True, registration_treasury=None)[source]

Prepare scripts for a CONTAINER (collection) reveal.

A container’s locking script is the plain 63-byte NFT singleton of build_nft_locking_script(). Container-ness is carried by 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:
  • child_ref (GlyphRef | None) – Removed. Passing anything but None raises ValidationError; see below.

  • commit_txid (str)

  • commit_vout (int)

  • cbor_bytes (bytes)

  • owner_pkh (Hex20)

  • pay_registration_fee (bool)

  • registration_treasury (str | None)

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.

If cbor_bytes is also marked WAVE and registers a name, the result carries its registration fee (pay_registration_fee, registration_treasury: as prepare_mutable_reveal()).

prepare_dat_commit(params)[source]

Prepare a DAT (data-storage) commit — a glyph whose commit demands no token.

Same two-transaction shape as prepare_commit(), but the commit carries no OP_REFTYPE_OUTPUT obligation: the reveal need create no NFT or FT (nor is it prevented). What survives is the reveal’s payload.

Protocol must include GlyphProtocol.DAT (3). Build the reveal 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 commit demands no token, 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:

ValidationError – authorised_refs is empty, or token_count is given without base_ref (or vice versa with no tokens to build).

Return type:

DelegateSetupScripts

prepare_dmint_deploy(params, *, allow_v2_deploy=True)[source]

Prepare a dMint token deploy.

Dispatches on the type of params:

  • DmintV1DeployParams → 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, *, allow_unregistrable_wave=False, pay_registration_fee=True, registration_treasury=None)[source]

Prepare scripts for a MUT (mutable NFT) reveal.

A payload marked WAVE is held to the WAVE claim rule (refuse_unregistrable_wave_claim()) unless allow_unregistrable_wave=True, which exists only to reveal a commit pyrxd ≤0.24.0 already broadcast; whether the claim so revealed registers is the indexer’s rule (see prepare_wave_reveal()), and when it does it owes the fee. How to rebuild that commit’s exact bytes is in prepare_wave_reveal().

A payload that registers a WAVE name owes the registration fee: registration_fee_output on the result, which the caller puts at vout 2 and funds from a plain wallet input (see prepare_wave_reveal()). pay_registration_fee=False declines it and registration_treasury redirects it; see wave_registration_fee_for().

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

  • nft_script: 63-byte NFT singleton (token the owner holds), 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:
  • commit_txid (str)

  • commit_vout (int)

  • cbor_bytes (bytes)

  • owner_pkh (Hex20)

  • allow_unregistrable_wave (bool)

  • pay_registration_fee (bool)

  • registration_treasury (str | None)

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.

A payload that registers a WAVE name also gets RevealScripts.registration_fee_output, which the reveal must pay unless params.pay_registration_fee is False. pyrxd glyph mint-nft reaches here with a WAVE metadata file, and puts that output at vout 1, after the NFT.

Parameters:

params (RevealParams)

Return type:

RevealScripts

prepare_wave_reveal(commit_txid, commit_vout, cbor_bytes, owner_pkh, name, *, allow_unregistrable_wave=False, pay_registration_fee=True, registration_treasury=None)[source]

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

THE RESULT CARRIES THE REGISTRATION FEE, AND THE CALLER PAYS IT. registration_fee_output is a WaveRegistrationFee: an output of wave_registration_price(name) photons — 100 RXD for a label of 3 characters or fewer, 50 for 4, 10 for 5, 5 for 6 to 63 — to the P2PKH of the WAVE treasury (WAVE_TREASURY_ADDRESS). This method returns scripts; the caller builds the transaction, in Photonic’s shape (mintToken) and mainnet claim f644794b…’s:

input 0   commit_txid:commit_vout       the commit (scriptSig: sig, pubkey, envelope)
input 1   commit_txid:commit_vout + 1   the seed for the contract's ref
input 2   a plain P2PKH wallet UTXO     funds the fee (f644794b: its commit tx's vout 2)
vout 0    nft_script                    the name's claim token
vout 1    contract_script               the mutable contract
vout 2    registration_fee_output       (value, locking_script)
vout 3+   change back to the wallet

FUND THE FEE FROM THE WALLET INPUT, NOT FROM THE COMMIT: then the fee is spent only by the reveal that registers the name, and a reveal that does not pay it (say, because the name was taken after the commit) leaves it in the wallet. Check that pyrxd.glyph.wave.WaveResolver.check_available() still reports no confirmed registration just before broadcasting (it does not see a rival claim that is not yet mined); pyrxd glyph mint-nft does all of this. pyrxd.glyph.wave_rules cites the sources.

pay_registration_fee=False registers the name without paying. The published protocol and Photonic expect the fee; RXinDexer does not check it at registration, so the name still resolves; renewing it later takes a transaction that spends the claim token and pays the price to the treasury. It is also how a commit whose name was taken in the meantime is recovered: the reveal is then a duplicate the indexer does not register. registration_treasury pays another P2PKH address instead — the published treasury is a MAINNET address, and no testnet or regtest one exists. See wave_registration_fee_for().

name is the name being claimed: "alice.rxd", or a bare "alice", which means the same. It must already be embedded in cbor_bytes, with the fields Photonic Wallet’s createWaveNameMetadata writes (packages/lib/src/wave.ts):

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

Use pyrxd.glyph.wave.build_wave_metadata() to construct it, and pass the same payload to prepare_commit() first — which applies the same rule, so a claim that would be refused here is refused before its commit can be broadcast.

REFUSED, by refuse_unregistrable_wave_claim() — the one rule every WAVE write door calls — any payload outside the rule pyrxd writes claims by, which is stricter than the indexer’s: a label outside 3-63 characters of lowercase a-z, 0-9 and - (so pyrxd’s own ≤0.24.0 shape, attrs.name = "alice.rxd", which the indexer does not register, and ab or Alice, which it does), a domain other than rxd, a claim with no attrs.name, and a top-level or app.data name naming a different claim. Then name must be the name the indexer will read from the payload.

allow_unregistrable_wave=True skips both checks and only confirms name is a name the payload carries. It is for ONE job: revealing a commit pyrxd ≤0.24.0 already broadcast, whose CBOR has the old shape. That commit can only be spent by revealing exactly those bytes, so refusing them would strand its value. WHETHER THE CLAIM SO REVEALED REGISTERS is the indexer’s rule, not pyrxd’s: a dotted attrs.name (the qualified name pyrxd ≤0.24.0 wrote when given one) does not, and the reveal only recovers the commit; a bare label the indexer accepts (≤0.24.0 wrote one when given a bare name such as "ab" or "Alice", which pyrxd now refuses) does, and owes the registration fee, which registration_fee_output carries — the fee follows wave_registered_label(), the indexer’s rule.

REBUILDING THOSE BYTES. Use the cbor_bytes stored when the commit was made (CommitResult.cbor_bytes) if you have them. build_wave_metadata() no longer produces them, but every release that shipped it (v0.6.0 to v0.24.0) wrote the same bytes, and this reproduces them:

from pyrxd.glyph.payload import encode_payload
from pyrxd.glyph.types import GlyphMetadata, GlyphProtocol

old_cbor, _ = encode_payload(
    GlyphMetadata(
        protocol=[GlyphProtocol.NFT, GlyphProtocol.MUT, GlyphProtocol.WAVE],
        attrs={
            "name": qualified_name,  # EXACTLY the string passed then: "alice.rxd"
            "domain": domain,  # the text after its LAST ".", or "rxd" if none
            "target": target,
            "target_type": target_type,  # "address" unless another was passed
        },
        description=description,  # the same description, "" if none was passed
    )
)

Checked byte for byte against the output of every tagged release from v0.6.0 to v0.24.0, recorded in tests/fixtures/wave_build_metadata_v0_6_to_v0_24.json (tests/test_wave_claim_registers_with_the_indexer.py, TestTheRecoveryRecipe). Before broadcasting, confirm hash_payload(old_cbor) == extract_payload_hash_from_commit_script(commit_script). If the bytes differ in any way, the commit script’s OP_HASH256 <hash> OP_EQUALVERIFY fails and consensus rejects the reveal. Nothing is lost: the commit stays unspent, and the reveal can be rebuilt with the right bytes and tried again.

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

The reveal shape is MUT’s, including its two-input requirement: the commit outpoint plus a seed outpoint at commit_vout + 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:
  • commit_txid (str)

  • commit_vout (int)

  • cbor_bytes (bytes)

  • owner_pkh (Hex20)

  • name (str)

  • allow_unregistrable_wave (bool)

  • pay_registration_fee (bool)

  • registration_treasury (str | None)

Return type:

MutableRevealScripts

class pyrxd.glyph.GlyphClient[source]

Bases: object

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

Usage:

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

Transfer-only callers can skip the store:

client = GlyphClient(electrumx, wallet)
receipt = await client.transfer_ft(ref, 250, recipient_pkh)
Parameters:
  • client – an ElectrumX-style client — await broadcast(raw_tx: bytes) -> txid, await get_transaction(txid), await get_utxos(script_hash), and await get_history(script_hash), which an HdWallet calls for the gap-limit scan collect_spendable runs before every spend.

  • 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:
  • CekCommitmentMismatch – sha256(cek) is not the token’s commitment. Publishing it would spend the reveal and leave the payload unreadable for good.

  • TimelockNotExpired – the lock has not expired and allow_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:

<0x04 nonce(4)> <0x20 inputHash(32)> <0x20 outputHash(32)> <OP_0>  → 72 bytes
<0x08 nonce(8)> <0x20 inputHash(32)> <0x20 outputHash(32)> <OP_0>  → 76 bytes

Where:

  • nonce — little-endian PoW nonce found by the miner. Its width does NOT say whether the contract is V1 or V2. Neither covenant checks it — each concatenates the nonce into the proof-of-work preimage (V1 epilogue 5a 7a 7e, V2 Part A 5e 7a 7e) — and V1 mints on mainnet use both widths: 465 of 473 mints of V1 contracts in a sample collected on 2026-09-23 pushed 8 bytes. The contract script the input spends is what tells V1 from V2 (DmintState.from_script). This payload used to carry a version_hint read off the width, which called those V1 mints “v2”; it carries nonce_width instead.

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

  • outputHash — SHA256d(OP_RETURN_msg_script at vout[2]).

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

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

Returns a dict with nonce_hex, input_hash, output_hash, nonce_width (4 or 8) 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_vout=None, loc_hash='', decimals=0, image_url='', image_ipfs='', image_sha256='', v=None, dmint_params=None, creator=None, royalty=None, policy=None, rights=None, created='', commit_outpoint='', timelock=None, encrypted_main=None, crypto=None, source_cbor=None, container_refs=(), author_refs=())
Parameters:
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 = ''
loc_vout: int | None = None

An INTEGER loc, kept rather than discarded. READ PATH ONLY — never encoded.

pyrxd reads loc as a text URI. Photonic reads it as a REF VOUT: an integer loc points at one of the token’s own refs, whose payload Photonic fetches and MERGES into this one (packages/app/src/electrum/worker/NFT.ts:988-1010). The two implementations do not mean the same thing by the field, and pyrxd was dropping the integer form with a warning — so a token whose real metadata lives in a second payload decoded as though it simply had no loc, indistinguishable from one that never had any. That is a silent partial read of third-party data.

THE MERGE IS NOT IMPLEMENTED. Following the pointer means fetching another output mid-decode, which this decode path deliberately does not do. Surfacing the vout is the honest half: a caller can see the payload is incomplete and go and get the rest. None means no integer loc was present, not that a merge was performed.

main: GlyphMedia | None = None
name: str = ''
policy: GlyphPolicy | None = None
rights: GlyphRights | None = None
royalty: GlyphRoyalty | None = None
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(Path("~/.pyrxd/pending-mints").expanduser())
minter = GlyphMinter(client, wallet, store)
result = await minter.mint_nft(metadata)

or, resumably:

pending = await minter.commit_nft(metadata)   # persisted, then broadcast
...                                           # crash, reboot, next week
pending = store.load(commit_txid)
result = await minter.reveal_nft(pending)
Parameters:
  • client – an ElectrumX-style client — await broadcast(hex) -> 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(). Once the reveal is reported confirmed the record is archived (PendingStore.archive()), never deleted; on failure it is kept where it is so the reveal can be retried. An archived record (JsonFilePendingStore.load_archived) can be passed here again — through another server, if the one that reported the reveal confirmed was wrong.

Parameters:
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, *, strict=False)[source]

Return all Glyph outputs currently owned at address.

Reads the script hashes the address’s token outputs are listed under (owned_token_script_hashes()), not its P2PKH hash: a Radiant ElectrumX lists a token output under its script with the refs zeroed, so the P2PKH hash lists the address’s plain outputs only. A token the server lists here whose owner is not address’s key is refused as a server inconsistency, not returned.

Parameters:
Returns:

Typed Glyph objects. 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, *, strict=False)[source]

Return all Glyph outputs for script_hash.

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

What the server says is checked against the transaction it serves, which get_transaction binds to the txid. A UTXO whose output is not at script_hash (or does not exist), or is listed twice, is a server inconsistency, and so is a token whose owner is not the address scan_address() was asked about. An FT’s amount is the output’s value in that transaction, never the server’s value. That a listed output is still unspent is the server’s word: nothing here checks it.

A UTXO whose raw transaction cannot be fetched or parsed, or that is a server inconsistency, is logged and left out by default. For a failed read the result is then a lower bound on what the script hash holds; an inconsistent item was never shown to be held here. strict=True raises NetworkError instead; use it when the result is shown as everything held (pyrxd glyph list does). A metadata lookup that fails does not count: it leaves the token in the result with metadata=None.

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

Parameters:
Return type:

list[GlyphNft | GlyphFt]

class pyrxd.glyph.HeightReport[source]

Bases: object

Where ONE endpoint places the mark and each step of the name’s chain.

judge_name_at_mark needs at least two of these from DIFFERENT sources, agreeing on every height it compares, before it will say which target was in force at the mark’s block. A report is the endpoint’s word, labelled — the judge does the comparing, so no caller can “corroborate” by assertion.

None / an absent txid means THIS endpoint does not place it in a block (it reports it unconfirmed, or does not list it at all). Two endpoints that both leave a step unplaced agree; one that places it and one that does not disagree, and that degrades.

__init__(source, mark_height, step_heights=<factory>, error='', mark_header_bound=False)
Parameters:
Return type:

None

error: str = ''

Why this endpoint’s heights could not be obtained, if they could not. An error is not a report that nothing is confirmed, and must not be read as one: it degrades, naming it.

mark_header_bound: bool = False

True when mark_height was bound to this endpoint’s own header (header_bound). The verdict’s caveat says the mark was header-checked only when EVERY report says so — a default of False makes the weaker sentence the one a caller gets without asking.

source: str

Who said this — the endpoint’s URL in the CLI. Compared across reports BY SOURCE (source_key()), never as raw text.

mark_height: int | None

The block this endpoint places the mark in.

step_heights: Mapping[str, int | None]

txid -> block height, for the chain steps this endpoint places in a block.

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.

directory is used as given: ~ is NOT expanded, so pass Path("~/.pyrxd/pending-mints").expanduser(), not the bare string, or the records land in a directory literally named ~ under the current one (#755).

ARCHIVE_DIRNAME: ClassVar[str] = 'done'

a subdirectory list_pending() does not read.

Type:

Where archive() moves a record

__init__(directory)[source]
Parameters:

directory (str | PathLike[str])

Return type:

None

archive(commit_txid)[source]

Move the live record to done/: a rename, so it keeps its 0600 mode, into a 0700 directory. Does nothing if there is no live record. See PendingStore.archive().

Raises ValidationError, leaving the record where it is, when done/ is not a real directory (archive_directory_problem(), which reads it with lstat). On POSIX the chmod and the rename act on a descriptor of done/ opened with O_NOFOLLOW (_open_archive()), never on its path, so neither can follow a link — not even one swapped in after the lstat (lane D F2: a chmod of the path followed a link and made a foreign directory 0700).

Parameters:

commit_txid (str)

Return type:

None

property archive_directory: Path

Where archived records are kept (<directory>/done).

classmethod archive_directory_problem(directory)[source]

Why the archive of a store at directory is unusable, or None. Creates nothing.

Read with lstat, so a symbolic link is seen as a link: done/ must be absent or a real directory. A regular file there makes the mkdir fail; a link can lead to another filesystem (a rename then fails with EXDEV) or to a directory that is not the store’s (on 3cb57e27 a chmod of the path followed one and made it 0700; lane D F2).

Parameters:

directory (str | PathLike[str])

Return type:

str | None

archive_problem()[source]

Why archive() could not work in this store, or None.

GlyphMinter asks before it broadcasts the commit and again before the reveal, and refuses on an answer: an archive that fails after a reveal is reported confirmed would otherwise be found only when the mint is finished. The default has nothing that can fail.

Return type:

str | None

delete(commit_txid)[source]

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

GlyphMinter never calls this: see archive(). It is here for a caller who has seen the reveal on chain for themselves and wants the record gone.

Parameters:

commit_txid (str)

Return type:

None

property directory: Path

Directory holding the records.

list_archived()[source]

Commit txids with an archived record. Refuses a done/ that is not a real directory.

Return type:

list[str]

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

load_archived(commit_txid)[source]

The archived record for commit_txid; PendingMintNotFound if there is none.

A read: it changes no mode and builds no second store (lane D N1 — a store built on done/ chmodded it by path, through a link). Refuses a done/ that is not a real directory, as archive() does.

Parameters:

commit_txid (str)

Return type:

PendingMint

restore(commit_txid)[source]

Move an archived record back to the live directory, to reveal it again — renamed out of a done/ descriptor opened with O_NOFOLLOW, as archive() renames into one.

Parameters:

commit_txid (str)

Return type:

None

save(pending)[source]

Persist pending, overwriting any record under the same commit txid.

Parameters:

pending (PendingMint)

Return type:

None

class pyrxd.glyph.MarkAnchor[source]

Bases: 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: it was not checked against any block header, and nothing checks proof-of-work or merkle inclusion, so an endpoint that lies about the height moves the point in time this answer is about', height_is_verified=False, header_bound=False, blockhash=None, verified_confirmations=None)
Parameters:
  • txid (str)

  • height (int | None)

  • confirmations (int)

  • min_confirmations (int)

  • source (str)

  • caveat (str)

  • height_is_verified (bool)

  • header_bound (bool)

  • blockhash (str | None)

  • verified_confirmations (int | None)

Return type:

None

blockhash: str | None = None

The block hash the endpoint named for the transaction (verbose blockhash), lowercase display hex — set only when header_bound, i.e. when the header at height hashed to it. None otherwise: an unbound hash would be a second unchecked claim.

caveat: str = 'height reported by the endpoint and NOT verified: it was not checked against any block header, and nothing checks proof-of-work or merkle inclusion, so an endpoint that lies about the height moves the point in time this answer is about'
header_bound: bool = False

True when height was bound to the endpoint’s own header (the header at height hashes to the block the endpoint named). A check of the endpoint against ITSELF — which is why height_is_verified stays False. Carried so a verdict built on the height can say truthfully whether the height was header-checked, rather than assuming either way.

height_is_verified: bool = False

a MarkAnchor is an endpoint’s word. The DISPLAY dict’s height_is_verified can become True only through with_block_verification(), given a VERIFIED outcome of pyrxd.glyph.mark_block.verify_mark_block().

Type:

Always False here

property provisional: bool

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

Judged against the PROVED depth when there is one (verified_confirmations), else the endpoint’s confirmations. A proved depth reaches the floor by construction, so it can only lift a mark the endpoint reports as shallow; one below the floor (built by hand) is provisional, whatever the endpoint reports.

property usable_for_point_in_time: bool

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

verified_confirmations: int | None = None

the default, and what every anchor an endpoint’s reply builds carries. Set only from a VERIFIED block verification of this height that reached min_confirmations (proven_depth()), so it can move the depth verdict from the endpoint’s word to proved and never below the floor. confirmations stays the endpoint’s figure either way.

Type:

The depth pyrxd PROVED for this block (with_proven_depth()), or None

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, *, registration_fee_output)
Parameters:
Return type:

None

mutable_ref: GlyphRef | None = None
ref: GlyphRef
nft_script: bytes
contract_script: bytes
scriptsig_suffix: bytes
payload_hash: bytes
registration_fee_output: WaveRegistrationFee | None

The WAVE registration fee this reveal must pay, or None. As RevealScripts.registration_fee_output; for a WAVE claim that is vout 2, after nft_script (vout 0) and contract_script (vout 1), where Photonic puts it.

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_change – False 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

wave_fee

for a payload that registers a WAVE name, what the minter chose to do about the registration fee: "pay" or "decline". None for every other record. pyrxd glyph resume-mint honours it, so an opt-out made at mint time survives a timeout or a crash instead of reverting to the paying default.

Type:

str | None

wave_treasury

the address the fee is paid to when the minter named one (--wave-treasury); None for the published treasury. Only with wave_fee="pay".

Type:

str | None

network

the network the commit was broadcast on (mainnet, testnet or regtest), or None when the writer did not say. pyrxd glyph mint-nft always says: one pending-mints directory serves every network a wallet is used on.

Type:

str | None

wave_fee, wave_treasury and network are the writer’s word, kept for comparison. pyrxd glyph resume-mint never lets them decide what is paid: a record is a file that can be changed after the mint, so the fee choice is stated on the resume command line and the record only cross-checks it.

__init__(commit_txid, commit_vout, commit_value, commit_script, cbor_bytes, owner_pkh, is_nft, carrier_value, fee_rate, funding_address, wave_fee=None, wave_treasury=None, network=None)
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)

  • wave_fee (str | None)

  • wave_treasury (str | None)

  • network (str | None)

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

network: str | None = None
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

wave_fee: str | None = None
wave_treasury: str | None = None
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.

archive(commit_txid)[source]

Retire the record of a mint whose reveal was REPORTED confirmed — without destroying it.

GlyphMinter calls this, never delete(), once its reveal is reported confirmed. “Confirmed” is the server’s word: a server that echoes a reveal it never relayed and reports it confirmed would otherwise have the only copy of the payload the commit can be spent with deleted while the commit is still unspent (#736, round 3). An archived record can be revealed again, through another server.

This default KEEPS the record where it is, so list_pending() goes on reporting it: a store that cannot archive errs toward keeping. Override it to move the record where list_pending() does not look (JsonFilePendingStore moves it to done/). Must not raise if the record is already gone.

Parameters:

commit_txid (str)

Return type:

None

archive_problem()[source]

Why archive() could not work in this store, or None.

GlyphMinter asks before it broadcasts the commit and again before the reveal, and refuses on an answer: an archive that fails after a reveal is reported confirmed would otherwise be found only when the mint is finished. The default has nothing that can fail.

Return type:

str | None

abstractmethod delete(commit_txid)[source]

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

GlyphMinter never calls this: see archive(). It is here for a caller who has seen the reveal on chain for themselves and wants the record gone.

Parameters:

commit_txid (str)

Return type:

None

durable: ClassVar[bool] = True

Whether save() actually persists. 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_hash – SHA256d(input_script) — push as scriptSig_inputHash.

  • output_hash – SHA256d(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} — checked, not assumed: any other shape, or the indexer’s {"error": ...}, raises RxinDexerError.

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} — checked, not assumed: any other shape, or the indexer’s {"error": ...}, raises RxinDexerError.

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 a lapsed name is not one the address holds in any useful sense.

These are names the address HOLDS (the WR owner index), not names whose target points at it: a holder can set a name’s target to any address (#729).

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.

GlyphMinter never calls this: see archive(). It is here for a caller who has seen the reveal on chain for themselves and wants the record gone.

Parameters:

commit_txid (str)

Return type:

None

durable: ClassVar[bool] = False

Whether save() actually persists. 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.

name is carried VERBATIM, not normalised. On a Photonic claim, and on one pyrxd builds now, it is the bare label ("alice") and domain holds the rest. On a claim pyrxd built through 0.24.0 it is the qualified name ("alice.rxd"), which RXinDexer does not index (see the module docstring). A reader that appends domain must check for a . first, or it prints alice.rxd.rxd for the older 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, height_sources=())
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)

  • height_sources (tuple[str, ...])

Return type:

None

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

one label per distinct source. Empty on a degrade.

Type:

The sources whose block heights agreed, on a form-2 verdict

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 by source key.

binding_verified: bool

nothing proves on chain that this glyph is the registration IN FORCE for the name. A form-2 verdict has checked that the glyph’s own mint NAMES it (rule 4), which is weaker — a duplicate claim or a lapsed registration names it too.

Type:

Always False

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.WaveRegistrationFee[source]

Bases: object

The output a reveal that registers a WAVE name pays to the protocol treasury.

Put TransactionOutput(Script(fee.locking_script), fee.value) in the reveal after the token outputs and before change — for a WAVE claim, vout 2, after the NFT and the mutable contract — and FUND IT FROM A PLAIN WALLET INPUT added to the reveal, not from the commit. That is Photonic’s shape (see the module docstring), and it keeps the fee in the wallet, where it can simply not be spent, until the moment the name is registered.

value and locking_script are DERIVED from the label and the treasury address and cannot be passed in, so a fee at the wrong tier, or paying a script that is not the treasury’s P2PKH, is not a value this type can hold.

__init__(label, treasury_address='1GrwkQNJfjbEJjH25heszNZLpbZou8nfXG')
Parameters:
  • label (str)

  • treasury_address (str)

Return type:

None

describe()[source]

"5 RXD (500,000,000 photons) to 1Grw…", for a confirmation or an error.

Return type:

str

property is_published_treasury: bool

Whether this pays the published mainnet treasury, WAVE_TREASURY_ADDRESS.

to_dict()[source]

JSON form, for --json output.

Return type:

dict[str, object]

treasury_address: str = '1GrwkQNJfjbEJjH25heszNZLpbZou8nfXG'

the published MAINNET treasury unless the caller names another.

Type:

Where the fee goes

label: str

The label the claim registers, as the indexer reads it ("alice"). "alice.rxd" is accepted and stored as "alice".

value: int

wave_registration_price() of label.

Type:

Photons

locking_script: bytes

OP_DUP OP_HASH160 <treasury hash160> OP_EQUALVERIFY OP_CHECKSIG.

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 the indexer has no confirmed registration of name.

RXinDexer answers from the blocks it has indexed, not from the mempool (electrumx/server/glyph_api.py:1498-1515 calls WaveIndex.check_available, electrumx/server/wave_index.py:1773-1800 at ca8a6a4e), so a rival claim that is broadcast but not yet mined is not seen: True is “no confirmed registration”, not “nobody else is registering it”.

SENDS THE LABEL, NOT THE QUALIFIED NAME — the same rule resolve() documents. 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:

ValidationError – issuer is empty, or expires is unparseable — caught here rather than at read time, because a mint is irreversible and an unparseable expiry silently reads as “never expires”.

Return type:

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:

ValidationError – amount is not an integer in 0..MAX_BURN_AMOUNT, 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:

ValidationError – miner_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:

ValidationError – unsigned_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, *, registration_fee='unstated')[source]

Build the scriptSig for spending a mutable NFT contract input.

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

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

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

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

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

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

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

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

  • registration_fee (WaveRegistrationFee | Literal['unstated'] | None) – Required only when cbor_bytes carries WAVE in p and would register: see below. Any other payload ignores it.

Return type:

bytes

An update envelope whose p carries WAVE is read by RXinDexer as a REGISTRATION of attrs.name, not as an update: process_tx sends everything WAVE-marked down the claim path (electrumx/server/wave_index.py:684-699 at ca8a6a4e). While the name is held and has not lapsed that registers nothing (a duplicate, wave_index.py:767-781); once it has lapsed, or for another name, it registers a new claim at this transaction’s vout 0 (:730, :782-807). So a WAVE-marked update is held to the claim rule and, like a reveal, must say what it pays for the name: registration_fee= a WaveRegistrationFee whose output goes in the update, or None. An update whose payload carries no p — the {attrs: {...}} shape the indexer’s own comment describes for a mod (wave_index.py:686-691) — is not a claim, and neither of these applies to it.

pyrxd.glyph.build_pow_preimage(txid_le, contract_ref_bytes, input_script, output_script)[source]

Build the PoW preimage AND the two script hashes the scriptSig must push.

preimage[0..32] = SHA256(txid_LE || contractRef) preimage[32..64] = SHA256(SHA256d(inputScript) || SHA256d(outputScript))

The covenant pulls 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='', expires=None)[source]

Construct a WAVE claim’s GlyphMetadata with Photonic’s fields.

Parameters:
  • qualified_name (str) – "<label>.rxd", or a bare "<label>", which means the same. The label must meet the rule in pyrxd.glyph.wave_rules — 3-63 characters of lowercase a-z, 0-9 and -, no leading or trailing -, and -- only in an xn-- punycode label — and the domain must be exactly rxd. Anything else is refused here, because the indexer would decline the claim after it had confirmed.

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

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

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

  • expires (int | None) – optional unix-seconds attrs.expires. Photonic always writes one (now + 2 years); pyrxd writes it only when given, so the bytes do not depend on the clock. It is display-level on both sides: RXinDexer stamps the real term from the registration BLOCK time and ignores this field for expiry.

Return type:

GlyphMetadata

The fields are those of Photonic’s createWaveNameMetadata (v 2, protocol [NFT, MUT, WAVE], top-level name = the qualified name, type = "wave_name", attrs = bare label, domain, target, target type). Pass the result to GlyphBuilder.prepare_commit() and then GlyphBuilder.prepare_wave_reveal(). Registering the name pays the WAVE registration fee: both return it as registration_fee_output (wave_registration_price(qualified_name) photons to the WAVE treasury), and the reveal must carry that output unless the caller passes pay_registration_fee=False. See pyrxd.glyph.wave_rules.

THE LABEL GOES IN attrs.name, NOT THE QUALIFIED NAME. Through 0.24.0 this function wrote the string it was given into attrs.name and left the top level empty. RXinDexer registers a claim from attrs.name and its validate_wave_name refuses the ., so a claim built from a qualified name ("alice.rxd", the form its 0.24.0 docstring showed) registered nothing (#728). One built from a bare label ("alice") wrote attrs.name = "alice", which registers alice.rxd. Both were checked by running the indexer’s own code at the pinned commit (tests/test_wave_fee_matches_the_pinned_indexer.py), not against a live indexer.

There is no homograph option. The label rule is ASCII-only, so a NON-ASCII look-alike label is refused by construction; a non-ASCII name is written as its xn-- punycode. An all-ASCII look-alike (paypa1) is not refused, and #698’s check did not refuse one either.

pyrxd.glyph.classify_glyph_metadata(metadata)[source]

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

Examples

[NFT, MUT, WAVE] → "wave" (when attrs.name present — including a

pyrxd ≤0.24.0 claim whose qualified attrs.name RXinDexer does not index)

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

The string mirrors GlyphOutput.glyph_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)

LEGACY ASERT target — the integer power-of-2 stepper baked into pre-resync contracts.

Off-chain mirror of pyrxd.glyph.dmint.builders._build_asert_daa_legacy(), the ASERT bytecode pyrxd emitted before 2026-09-16 (upstream replaced it with ASERT-v2 on 2026-06-19, ed53cd41). A contract deployed under it MUST keep being mined with THIS formula — the covenant recomputes the next target with the bytecode it was born with, and rejects any other value. build_dmint_mint_tx dispatches here when detect_contract_daa_bytecode() reports DaaBytecodeVersion.LEGACY; new deploys use compute_next_target_asert_v2().

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

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

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

Both subtractions go through _script_int64(), as in the v2 mirror: for a target_time near the int64 limit the on-chain OP_SUB aborts, so this raises ValidationError rather than return a target the contract cannot compute.

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_asert_legacy(current_target, last_time, current_time, target_time, half_life)[source]

LEGACY ASERT target — the integer power-of-2 stepper baked into pre-resync contracts.

Off-chain mirror of pyrxd.glyph.dmint.builders._build_asert_daa_legacy(), the ASERT bytecode pyrxd emitted before 2026-09-16 (upstream replaced it with ASERT-v2 on 2026-06-19, ed53cd41). A contract deployed under it MUST keep being mined with THIS formula — the covenant recomputes the next target with the bytecode it was born with, and rejects any other value. build_dmint_mint_tx dispatches here when detect_contract_daa_bytecode() reports DaaBytecodeVersion.LEGACY; new deploys use compute_next_target_asert_v2().

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

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

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

Both subtractions go through _script_int64(), as in the v2 mirror: for a target_time near the int64 limit the on-chain OP_SUB aborts, so this raises ValidationError rather than return a target the contract cannot compute.

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_asert_v2(current_target, last_time, current_time, target_time, half_life)[source]

ASERT-v2 next target — the fractional, symmetric, damped retarget of CURRENT deploys.

Off-chain mirror of pyrxd.glyph.dmint.builders._build_asert_daa_v2(), which is the canonical Photonic buildAsertDaaBytecode (script.ts at becf41a), and an exact port of the reference computeAsertV2Target in packages/lib/src/dmintDaaV2.ts (same commit) — bigint arithmetic there, so the only semantic to carry over is that BigInt and OP_DIV both truncate toward zero:

excess    = (current_time - last_time) - target_time
driftFp   = trunc((excess * RADIX) / half_life)        # RADIX = 2^16, signed
driftFp   = clamp(driftFp, -RADIX/4, +RADIX/4)         # ±25%/mint damping
t         = min(current_target, MAX_TARGET/4)          # difficulty floor 4
new       = clamp(t + (t / RADIX) * driftFp, 1, MAX_TARGET/4)

_trunc_div is load-bearing: OP_DIV truncates toward zero while Python // floors, and they differ for the negative excess of an early block (e.g. excess = -1, half_life = 240 → trunc(-65536/240) = -273; floor gives -274). A floor here would recreate a target the covenant does not, and the mint is rejected.

int64 bounds, every intermediate (the covenant aborts, not wraps, outside [-(2^63-1), 2^63-1]; this mirror raises ValidationError at the same points via _script_int64() so it never predicts an unmineable state):

  • timeDelta = current_time - last_time: for an ASERT/LWMA mint build_dmint_mint_tx requires both current_time and the state’s last_time to be readable lastTimes (is_readable_last_time: [2^23, 2^31 - 1]), so |timeDelta| <= 2^31 - 1 in either direction.

  • excess = timeDelta - target_time: target_time is a state script number in [1, 2^63 - 1], so excess is in [-(2^63 - 1) - (2^31 - 1), 2^31 - 2] and the OP_SUB underflows only for target_time > 2^63 - 1 + timeDelta — checked.

  • excess * RADIX: |excess| <= 2^47 - 1 ⇒ |excess * 2^16| <= 2^63 - 2^16, representable; |excess| >= 2^47 ⇒ |product| >= 2^63, which OP_MUL aborts. Positive excess never reaches it (excess <= 2^31 - 2); negative excess reaches it only for target_time >= 2^47 + timeDelta (≈ 1.4e14 s ≈ 4.5 Myr) — checked. Photonic’s proof (|excess| <= ~4.3e9 → |product| <= ~2.8e14 < 2^63) is the realistic-deploy case of the same bound.

  • driftFp = trunc(excess*RADIX / half_life): |driftFp| <= |excess*RADIX| since half_life >= 1; then clamped to |driftFp| <= 2^14.

  • t = min(current_target, MAX/4) <= 2^61 - 1; t / RADIX <= 2^45 - 1.

  • delta = (t/RADIX) * driftFp: |delta| < 2^45 * 2^14 = 2^59 — cannot overflow.

  • t + delta < 2^61 + 2^59 < 2^63 — cannot overflow.

So the only reachable abort is the excess chain for an absurd target_time, and it is reported rather than mis-predicted.

Note

V2-only DAA. current_target must itself be a valid script number (1..MAX_SHA256D_TARGET): the covenant compares it with CScriptNum ops, so a target wider than 8 bytes cannot run through any DAA on chain — for any algorithm.

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)

LEGACY LWMA target — the unity-gain single-sample retarget of pre-resync contracts.

Off-chain mirror of pyrxd.glyph.dmint.builders._build_linear_daa_legacy() (with the OP_0 OP_MAX timeDelta floor) AND of _build_linear_daa_legacy_prefloor() (without it) for every current_time >= last_time. On a negative delta the two variants differ; this mirror gives the floored variant’s answer, and build_dmint_mint_tx does not build a backwards mint of a pre-floor contract. This is the LWMA bytecode pyrxd emitted before 2026-09-16 (upstream replaced it with LWMA-v2 on 2026-06-20, c90e6506); the mainnet LWMA deploy dea3beb9… carries the pre-floor variant and is mined with this formula. New deploys use compute_next_target_linear_v2().

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

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

The MAX/4 target cap means LWMA contracts cannot have a difficulty floor below 4 (target <= MAX_TARGET/4). The 0-floor 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.

The two operations the bytecode can abort on — the OP_SUB of the times and the OP_4 OP_MUL of target_time — go through _script_int64(), so an input the contract cannot evaluate raises ValidationError instead of returning a target.

Note

V2-only DAA.

Parameters:
  • current_target (int)

  • last_time (int)

  • current_time (int)

  • target_time (int)

Return type:

int

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

LEGACY LWMA target — the unity-gain single-sample retarget of pre-resync contracts.

Off-chain mirror of pyrxd.glyph.dmint.builders._build_linear_daa_legacy() (with the OP_0 OP_MAX timeDelta floor) AND of _build_linear_daa_legacy_prefloor() (without it) for every current_time >= last_time. On a negative delta the two variants differ; this mirror gives the floored variant’s answer, and build_dmint_mint_tx does not build a backwards mint of a pre-floor contract. This is the LWMA bytecode pyrxd emitted before 2026-09-16 (upstream replaced it with LWMA-v2 on 2026-06-20, c90e6506); the mainnet LWMA deploy dea3beb9… carries the pre-floor variant and is mined with this formula. New deploys use compute_next_target_linear_v2().

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

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

The MAX/4 target cap means LWMA contracts cannot have a difficulty floor below 4 (target <= MAX_TARGET/4). The 0-floor 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.

The two operations the bytecode can abort on — the OP_SUB of the times and the OP_4 OP_MUL of target_time — go through _script_int64(), so an input the contract cannot evaluate raises ValidationError instead of returning a target.

Note

V2-only DAA.

Parameters:
  • current_target (int)

  • last_time (int)

  • current_time (int)

  • target_time (int)

Return type:

int

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

LWMA-v2 next target — the damped fractional retarget of CURRENT LWMA deploys.

Off-chain mirror of pyrxd.glyph.dmint.builders._build_linear_daa_v2() (canonical Photonic buildLinearDaaBytecode at becf41a) and a port of computeLwmaV2Target in packages/lib/src/dmintDaaV2.ts: exactly compute_next_target_asert_v2() with the responsiveness gain auto-set to target_time — driftFp = (excess * RADIX) / target_time — so a block twice the target time hits the ±25% clamp. Same int64 proof (target_time >= 1 is deploy-enforced, so the OP_DIV never divides by zero).

The old unity-gain LWMA (compute_next_target_linear_legacy()) could jump difficulty 4× in one block and slam the target to 1 on a zero-delta block; this caps each step at ±25% and is stable on-target.

Note

V2-only DAA.

Parameters:
  • current_target (int)

  • last_time (int)

  • current_time (int)

  • target_time (int)

Return type:

int

pyrxd.glyph.detect_contract_daa_bytecode(contract_script)[source]

Which ASERT/LWMA retarget-formula generation a V2 dMint contract script bakes.

Parses the state (for the declared daa_mode and the state/code boundary) and classifies the code section with pyrxd.glyph.dmint.builders.detect_daa_bytecode(). For ASERT the result also carries the half_life read out of the bytecode — the value a miner MUST use.

Raises:
  • ValidationError – not a V2 contract script, or its DAA mode is not ASERT/LWMA (FIXED/EPOCH/SCHEDULE have a single generation; build_dmint_mint_tx byte-verifies those by rebuilding Part B from the deploy parameters).

  • UnrecognizedDaaBytecodeError – the fragment matches no known generation.

Parameters:

contract_script (bytes)

Return type:

DetectedDaaBytecode

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

Convert difficulty to PoW target: MAX_SHA256D_TARGET // difficulty for every algo.

algo is accepted for backward compatibility and does not change the result — see target_for_difficulty(), the single definition this and DmintDeployParams.initial_target share. BLAKE3/K12 once got (2**256 - 1) // difficulty here, a target no dMint covenant can read.

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). A pyrxd ≤0.24.0 claim, whose attrs.name is the qualified name, IS returned, with name verbatim — see WaveAttrs.

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, name, binding_source, anchor, walk, height_reports, operators=None)[source]

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

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

name is the name that was ASKED about (alice.rxd or alice); the glyph’s own mint payload must claim it. height_reports is one HeightReport per endpoint; at least two distinct sources must agree on the mark’s height (which must equal anchor.height) and on the height of every walked step. A step whose agreed height is unknown cannot be placed relative to the mark, so the verdict degrades unless it is already known to fall after it.

Source labels are compared by source key. operators maps a label (a URL) to the operator declared for it — the CLI passes its config’s declarations (pyrxd.cli.config.Config.declared_operators()). It is the only way a declaration reaches this judge: nothing declared elsewhere in the process is seen. None means none declared.

Parameters:
Return type:

WaveIdentityVerdict

pyrxd.glyph.mark_anchor_dict(anchor, verification=None, *, verified_by=None)[source]

The display shape of a MarkAnchor.

caveat and height_is_verified are carried, never dropped: without a VERIFIED verification the height is one endpoint’s claim — at most checked against that endpoint’s own header. A consumer that shows the number and not the caveat has published the unqualified sentence this module exists to prevent.

verification is an optional BlockVerification for this anchor’s block, applied by with_block_verification() (see there); without one, block_verification and verified_confirmations are None and nothing else changes.

Parameters:

verified_by (str | None)

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:
  • ValidationError – preimage 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.

  • NotImplementedError – algo 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) – The contract’s PoW algorithm. Only SHA256D can be mined: BLAKE3 and K12 raise NotImplementedError on BOTH paths, before any hashing. (This used to be ignored on the external-miner path, whose protocol carries no algorithm field and whose answer is re-verified with SHA256d — so a BLAKE3/K12 caller got a SHA256d grind, not a refusal.)

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

  • NotImplementedError – algo is BLAKE3 or K12 (either path).

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, algo=DmintAlgo.SHA256D)[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.

  • algo (DmintAlgo) – The contract’s PoW algorithm. The protocol carries no algorithm field and the answer is re-verified with SHA256d, so anything but SHA256D is refused before the miner is spawned. Pass the contract’s own state.algo; the default keeps older callers working.

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

  • NotImplementedError – algo is BLAKE3 or K12.

  • MaxAttemptsError – The miner exceeded timeout_s.

  • FileNotFoundError – miner_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

pyrxd.glyph.proven_depth(verification, *, height, min_confirmations)[source]

The depth verification PROVED for the block at height, or None when it proved none.

THE ONE PREDICATE for “this block is verified, deep enough”. Non-None exactly when verification is VERIFIED, carries a claim, is about height, and proved a depth (verified_depth) that reaches min_confirmations. with_block_verification() (the display dict: the block check) and with_proven_depth() (the anchor a form-2 name judgement reads) both decide through it, so the two cannot disagree about whether the mark’s block was proved deep enough.

Parameters:
Return type:

int | None

pyrxd.glyph.require_mineable_last_time(last_time, daa_mode, *, stage)[source]

Refuse a last_time that would make the deployed contract unmineable.

The V2 state pushes lastTime as a fixed 04 <4B LE>. ASERT and LWMA open their retarget with OP_TXLOCKTIME OP_2 OP_PICK OP_SUB, so the FIRST mint builds a CScriptNum from whatever the deploy put there — and SCRIPT_VERIFY_MINIMALDATA is in radiant-core’s MANDATORY_SCRIPT_VERIFY_FLAGS, i.e. CONSENSUS, not mempool policy. A non-minimal 4-byte value (anything below 2**23, and the default 0 above all) aborts that script: the contract can never be mined, and nothing can fix it once the reveal is confirmed. The 2**23 boundary was measured against a real radiant-core node by the 2026-09-21 review (8388608 accepted, 8388607 rejected with mandatory-script-verify-flag-failed); the lastTime = 0 half is re-proved on every regtest run by the control in tests/test_dmint_v2_regtest_e2e.py.

A value with bit 31 set (above 0x7FFFFFFF) is refused too, although its encoding can be minimal: the top bit of a script number is its sign, so the covenant’s first retarget would measure from a NEGATIVE lastTime while pyrxd’s miner, which parses the state unsigned, refuses to mine such a state rather than predict a target the covenant does not compute (see pyrxd.glyph.dmint.types.is_readable_last_time()). pyrxd does not deploy a contract its own miner would refuse.

This is enforced on the DEPLOY path rather than in DmintDeployParams, because that type is the argument to pyrxd’s byte-level mirror of Photonic dMintScript — which accepts any lastTime — and the mirror has to stay able to reproduce contracts that already exist on chain. Every shipped caller of build_dmint_contract_script must cross this guard: tests/test_reachability_shipped_callers.py derives the set of those callers from the source and fails if any one of them does not also call this function. (It asserts that universality, not a count of callers.)

Parameters:
Return type:

None

async pyrxd.glyph.resolve_mark_anchor(*, txid, fetch_verbose, source, min_confirmations, tip_height=None, fetch_header=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.

fetch_header (ElectrumXClient.get_block_header-shaped) BINDS the derived height to the block the endpoint says holds the transaction: the header at that height must hash to the verbose reply’s blockhash. Without it the height is tip - confirmations + 1 alone, which is one block LOW whenever an endpoint’s index trails its node — measured on both shipped servers at once, so a second endpoint agreeing does not catch it. With it, heights up to MAX_INDEX_LAG_BLOCKS above the formula are tried, and a height no header confirms is never returned: that raises instead. The CLI always passes it.

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

ValidationError – sale_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"), for LOOKUPS.

Names with no domain (e.g. "alice") default to domain "rxd". Names with multiple dots use the LAST dot as the domain separator (so "foo.bar.rxd" is ("foo.bar", "rxd")) — which is NOT how Photonic splits (it takes the first dot), so this is not used to BUILD a claim: build_wave_metadata() and GlyphBuilder.prepare_wave_reveal() use parse_wave_name(), which refuses any name that is not <label>.rxd. Here, a dotted label reaches the indexer as-is and is refused there.

Parameters:

qualified (str)

Return type:

tuple[str, str]

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

Convert PoW target to difficulty (approximate): MAX_SHA256D_TARGET // target.

The inverse of difficulty_to_target(), and like it independent of algo (accepted for backward compatibility only).

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, operators=None)[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.

operators maps a source label (a URL) to its declared operator, as the caller’s config declares it; it is the only way a declaration reaches this comparison.

Raises:

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

Parameters:
Return type:

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). Like extract_wave_attrs(), it returns a pyrxd ≤0.24.0 claim’s qualified attrs.name verbatim.

Parameters:

metadata (GlyphMetadata)

Return type:

WaveAttrs | None

pyrxd.glyph.wave_registration_fee_for(cbor, *, pay_registration_fee=True, registration_treasury=None)[source]

The registration fee a transaction carrying cbor pays, or None when it pays none.

Every GlyphBuilder reveal method that can register a name, the reveal-fee estimator in pyrxd.glyph.fees and pyrxd glyph mint-nft derive the fee here.

None when cbor registers no name (wave_registered_label()) or when pay_registration_fee=False.

Parameters:
  • pay_registration_fee (bool) – True unless the caller explicitly opts out. With False the name is registered WITHOUT paying the fee. Three facts to weigh first. The published WAVE protocol and Photonic expect the fee on every registration. RXinDexer does not check it at registration, so the name still registers and resolves: its claim path (wave_index.py:706-807 at ca8a6a4e) never reads the outputs. And renewing the name takes a transaction that spends its claim token and pays the name’s price to the treasury (wave_index.py:581-642; WAVE-Protocol ANNOUNCEMENT.md:52). False is also the way to recover a commit whose name was taken before it could be revealed: the reveal is then a duplicate claim the indexer does not register, and the commit’s value comes back as an NFT carrier plus change. Must be a bool: a truthy string such as "False" would otherwise pay, and 0 would opt out without saying so.

  • registration_treasury (str | None) – a P2PKH address to pay instead of WAVE_TREASURY_ADDRESS. The published treasury is a MAINNET address and no testnet or regtest treasury is published, so on those chains name one here or pass pay_registration_fee=False. Refused together with pay_registration_fee=False, which would ignore it.

  • cbor (bytes | dict[str, Any])

Return type:

WaveRegistrationFee | None

pyrxd.glyph.wave_registration_price(name)[source]

Registration price in photons for name (a bare label, or the label plus .rxd).

Tiered by the length of the label, as Photonic’s calculateNameCost and RXinDexer’s wave_name_price are (see the module docstring):

label

photons

RXD

1-3

10,000,000,000

100

4

5,000,000,000

50

5

1,000,000,000

10

6-63

500,000,000

5

Any label RXinDexer registers has a price, including the 1-2 character labels and the upper-case ones pyrxd itself will not write: the indexer registers those (_indexer_name_problem()), and both sources price “3 or fewer” at 100 RXD, so a 1-2 character label costs 100 RXD, never less. A name RXinDexer refuses (a . in the label, over 63 characters, a leading -) has no price and raises ValidationError.

Parameters:

name (str)

Return type:

int

pyrxd.glyph.with_block_verification(shape, verification, *, verified_by=None)[source]

shape (a mark_anchor_dict()) with a block verification’s outcome applied — a copy.

THE ONLY PLACE height_is_verified BECOMES TRUE. It is True exactly when verification is VERIFIED, is about the height shape reports, and proved a depth that reaches the floor shape reports; the caveat is then the verifier’s own claim sentence (what was proved, and what was not). Otherwise height_is_verified is False and the caveat stays the anchor’s own endpoint’s-word caveat — except when the merkle branch was checked and passed but the height was still not verified: then it is INCLUSION_ONLY_CAVEAT, because BOUND_CAVEAT’s “pyrxd checks no … merkle inclusion” would be false. Inclusion alone never makes the height verified.

block_verification carries the whole outcome (every BlockVerification field) plus source, the endpoint whose data was checked (verified_by); None when verification is. When VERIFIED, blockhash is the hash of the block proved, which is not necessarily the one the endpoint named (see named_blockhash).

header_bound IS NOT REWRITTEN, because it is not about the block proved. It says the endpoint’s height was checked against the endpoint’s own header, and that header is the block the endpoint NAMED: block_verification.named_blockhash when that is set (the proof served a different block at that height), otherwise blockhash. So header_bound: true beside a blockhash never asserts that the endpoint’s single header was that block’s unless named_blockhash is null.

TWO DEPTHS, NAMED APART. confirmations is always the ENDPOINT’S figure, verified or not. verified_confirmations is the depth the verifier proved (its verified_depth: a lower bound, the mark’s block counting as 1) when VERIFIED, and None otherwise. When VERIFIED, the floor is judged against the PROVED depth — provisional and deep_enough follow it, not the endpoint’s number, which may be larger or smaller and is only a claim; VERIFIED requires the proved depth to reach min_confirmations, so a VERIFIED anchor is never provisional. Otherwise both stay the endpoint’s word, as mark_anchor_dict set them.

Parameters:
Return type:

dict

pyrxd.glyph.with_proven_depth(anchor, verification)[source]

anchor with the depth verification proved (proven_depth()) — a copy.

For a caller that judges the anchor’s depth (provisional, usable_for_point_in_time) after verifying its block: a VERIFIED block is judged by the depth proved, anything else by the endpoint’s figure, exactly as the display dict’s are (with_block_verification()).

THE CAVEAT FOLLOWS THE SAME RULE, because a form-2 verdict that degrades hands the anchor’s caveat on as its own: it is the verifier’s claim when the depth was proved, and INCLUSION_ONLY_CAVEAT when the merkle branch passed and the height still did not verify — the caveat with_block_verification() gives the display dict — so neither says “pyrxd checks no … merkle inclusion” beside a block line saying it did (#806). Nothing else changes: the height, the endpoint’s confirmations and source are still the endpoint’s report, and height_is_verified stays False (it is a property of the display dict).

Parameters:
Return type:

MarkAnchor