pyrxd.script — Script types and evaluation¶
Script types, templates, and evaluation for Radiant transactions.
The public re-exports below are resolved lazily via PEP 562
__getattr__ so importing pyrxd.script.script (used by the
inspect tool’s Transaction parser) doesn’t transitively pull
pyrxd.script.type and through it pyrxd.keys → coincurve.
See pyrxd for the broader rationale on lazy public re-exports.
- class pyrxd.script.BareMultisig[source]¶
Bases:
ScriptTemplate
- class pyrxd.script.CsvKind[source]¶
Bases:
EnumRelative time-lock kind, per BIP-112.
- BLOCKS = 'blocks'¶
- TIME_512_SECONDS = 'time'¶
- class pyrxd.script.OpReturn[source]¶
Bases:
ScriptTemplate- lock(pushdatas, *, provably_unspendable=False)[source]¶
Build a data-carrier output.
Emits a BARE
OP_RETURN. Radiant Core classifies an output asTX_NULL_DATAonly whenscriptPubKey[0]isOP_RETURN(src/script/standard.cpp,Solver());OP_FALSE OP_RETURN, which this builder emitted until 2026-09-02, falls through toTX_NONSTANDARD.NEITHER classification has any force on Radiant, so both spellings relay.
IsStandardTxis reached from exactly one place (validation.cpp:586@v3.1.2) and is gated onfRequireStandard, hardcodedfalse(validation.cpp:271) — the same factglyph/ft.pyandglyph/builder.pyrest on. The default is a matter of convention, not of policy.The convention is real, though. Measured on Radiant mainnet, 20 consecutive recent blocks: 232 bare
OP_RETURNdata outputs, and ZERO of theOP_FALSE-prefixed form.provably_unspendable=Truerestores the prefixed form. Core’sCScript::IsUnspendable()(script.h:882-885) detects only that spelling, so it is the one a pruning helper recognises — but Core is internally inconsistent here (Solver()’s comment claims to use that test and its code does not).Both forms are unspendable, by two DIFFERENT mechanisms; only the prefixed one is FLAGGED as such by Core. Prefixed:
OP_FALSEleaves an element on the stack, soOP_RETURNfails withScriptError::OP_RETURN(interpreter.cpp:564-571). Bare: an emptyscriptSigleaves the stack empty, and thereOP_RETURNdoes NOT abort — it terminates evaluation as SUCCESSFUL (same lines); the spend then fails onVerifyScript’s empty-final-stack check,ScriptError::EVAL_FALSE(interpreter.cpp:3092-3094). Any non-emptyscriptSigfails at theOP_RETURNitself instead.
- class pyrxd.script.P2PK[source]¶
Bases:
ScriptTemplate
- class pyrxd.script.P2PKH[source]¶
Bases:
ScriptTemplate
- class pyrxd.script.Script[source]¶
Bases:
object- __init__(script=None, *, allow_malformed=False)[source]¶
Create script from hex string or bytes.
The chunk walk mirrors Radiant’s
GetScriptOp(src/script/script.cpp:662-731) exactly, including its refusal to clamp a push whose declared size runs past the end of the script.allow_malformedis the deliberate, opt-in escape hatch and has exactly one legitimate user: transaction deserialization. A scriptPubKey is only executed when it is spent, so a transaction carrying an unwalkable output script is perfectly valid in a block, and a deserializer that refused it could not read real chain history. In that mode the walk stops whereGetOpreturns false and records the offset intruncated_at; it never invents a chunk. Raw bytes are preserved verbatim either way, matchingCScript, which stores bytes and only fails when something walks them.
- classmethod from_chunks(chunks)[source]¶
Reassemble a script from chunks, byte-for-byte.
Each chunk re-emits its own declared encoding (
ScriptChunk.serialize()). It used to route everything throughencode_pushdata, which rewrites a non-minimal push into the minimal one — sofrom_chunks(Script(x).chunks)was not the identity, andfind_and_deletecould change a script it deleted nothing from.- Parameters:
chunks (list[ScriptChunk])
- Return type:
- is_push_only()[source]¶
Checks if the script contains only push data operations. :return: True if the script is push-only, otherwise false.
Mirrors
CScript::IsPushOnly(script.cpp:537-553), which returns false the momentGetOpfails — before it ever looks at the opcode. A script that stopped short is therefore NOT push-only, however push-like the chunks it did manage to yield look. This matters:SCRIPT_VERIFY_SIGPUSHONLYis applied to every connected block, so answering “yes” here for a truncated scriptSig calls valid an input no block can contain.- Return type:
- to_asm()[source]¶
Disassemble to ASM, marking an unwalkable tail rather than hiding it.
Radiant’s
ScriptToAsmStr(src/core_write.cpp:117-120) emits[error]and stops as soon asGetOpfails. Rendering clean ASM for a script the node cannot read is how a malformed script gets eyeballed as fine.- Return type:
- class pyrxd.script.ScriptChunk[source]¶
Bases:
objectA representation of a chunk of a script, which includes an opcode. For push operations, the associated data to push onto the stack is also included.
dataalso carries the fixed 36-byte immediate of a ref opcode, which is an operand rather than a stack push —is_ref_operand()tells the two apart, because they re-serialize differently.- is_ref_operand()[source]¶
True when
opis one of the five opcodesGetScriptOpfollows with a bare 36-byte operand (no length prefix).- Return type:
- serialize()[source]¶
The exact bytes this chunk was parsed from.
Re-emits the declared push encoding rather than the minimal one. The previous implementation ran every chunk through
encode_pushdata, which normalises, so a script using a non-minimal push came back as different bytes — and anything hashing the result (a txid, a covenant’s codeScriptHash) was hashing a script the caller never had.- Return type:
- class pyrxd.script.Unknown[source]¶
Bases:
ScriptTemplate
- pyrxd.script.algorithm_for(algorithm_id=1)[source]¶
The hash algorithm algorithm_id names (§5.3), or raise if unimplemented.
Public because whoever is about to mark a file has to run the right hash over it, and the only authority on which one that is is the table the encoder writes into the record’s header byte. A caller that spells
"sha256"itself has created a second source of truth for what a record CLAIMS versus what was actually hashed, and nothing downstream can detect the disagreement: both halves are well-formed, the signature verifies, and the record is simply false.The name is the one
hashlibknows, which is what makespyrxd.hashmark_tx.digest_file()able to derive its hasher from the id rather than from a second table.
- pyrxd.script.build_csv_sequence(units, kind)[source]¶
Encode an
(units, kind)pair into the integer form CSV expects on the stack and in the spending input’snSequencefield.unitsis the BIP-112 unit count: blocks forCsvKind.BLOCKS, or 512-second intervals forCsvKind.TIME_512_SECONDS. Must be in the range[0, 65535](16 bits).
- pyrxd.script.build_p2pkh_with_cltv_script(owner_pkh, locktime)[source]¶
Build a P2PKH locking script gated by an absolute time-lock (CLTV).
The output is spendable only when the spending transaction’s
nLockTimeis at or afterlocktime.locktime < 500_000_000selects a block-height lock; values at or aboveLOCKTIME_THRESHOLDselect a Unix-time lock (seconds). The caller is responsible for choosing the right interpretation — both are accepted at the script level.Returns the raw locking-script bytes.
- pyrxd.script.build_p2pkh_with_csv_script(owner_pkh, sequence)[source]¶
Build a P2PKH locking script gated by a relative time-lock (CSV).
The output is spendable only after the BIP-112-encoded
sequencehas elapsed (measured from the funding-output’s confirmation). Usebuild_csv_sequence(units, kind)to constructsequencefrom a block count or a 512-second interval count.Callers must NOT pass a sequence with the disable bit (1 << 31) set — that value means “no relative lock” and would silently make the script trivially spendable.
Returns the raw locking-script bytes.
- pyrxd.script.canonicalize_label(label)[source]¶
The canonical spelling of label per §5.4 — trimmed and NFC — or raise.
§5.4 makes this an encoder obligation: “Encoders must trim leading and trailing whitespace and normalize to Unicode NFC before measuring, signing and writing, and must show the user the resulting canonical label, because that is what will be published.”
It is DELIBERATELY not folded into
encode_hashmark(), which refuses a non-canonical label instead. A library function cannot “show the user” anything, and in v2 the label is inside the signed statement — so an encoder that silently trimmed would sign a string its caller never saw. Splitting it means the transformation happens where a human can be shown the result, and the signing path only ever handles a label that is already final.Rejected codepoints (§5.4’s table) are refused BEFORE trimming rather than after. Python’s
str.strip()treats U+2028 and U+2029 as whitespace, so trimming first would silently swallow a line separator sitting at either end — a codepoint the spec lists precisely because it hides what is rendered. Refusing costs a caller one edit; swallowing costs a reader the truth.
- pyrxd.script.decode_hashmark(script)[source]¶
Decode a
scriptPubKeyas a HashMark record.Never returns a partial or best-effort result — the outcome is one of the five in
HashMarkOutcome, and a caller must branch on it.- Parameters:
script (bytes)
- Return type:
HashMarkRecord
- pyrxd.script.encode_hashmark(digest, private_key, *, label=None, algorithm_id=1, network_genesis='0000000065d8ed5d8be28d6876b3ffb660ac2a6c0ca59e437e1f7a6f4e003fb4')[source]¶
Build a signed v2 HashMark
scriptPubKeycommitting to digest.- Parameters:
digest (bytes) – the raw digest bytes. Its length must equal the width algorithm_id declares (§5.3) — a 31-byte sha256 digest is refused here, not padded.
private_key (PrivateKey) – the key that makes the statement. Its
compressedflag drives BOTH the committedhash160and the signature header’s compression bit; they are read from one local so they cannot diverge, because a record whose header disagrees with its commitment recovers a different key and can never verify.label (str | None) – an optional public caption, already canonical — pass it through
canonicalize_label()first and show the user the result.network_genesis (str) – the genesis hash of the chain this will be broadcast to, in RPC/display order. It is NOT carried by the record: it is part of the signed statement, so the same bytes on another chain make a different statement and will not verify there (§5.6, §2.10). Defaulting to mainnet is deliberate — a testnet mark must be an explicit act.
algorithm_id (int)
- Return type:
The label is permanently public and the signature permanently links this mark to that key and to every other mark it signed (§5.4, §14.1). A caller with a user in front of it must say so before this is broadcast.
- pyrxd.script.max_label_bytes(algorithm_id=1)[source]¶
The v2 label cap in BYTES for algorithm_id, derived per §5.4 (88 for sha256).
Public because it is a number a caller has to show a user BEFORE they type a label — “up to 88 bytes” is useful, “your label was rejected” after the fact is not. Derived from the record ceiling rather than tabulated, so registering a longer digest shrinks the label visibly instead of silently producing records that stop relaying.
- pyrxd.script.verify_attestation(record, *, network_genesis='0000000065d8ed5d8be28d6876b3ffb660ac2a6c0ca59e437e1f7a6f4e003fb4')[source]¶
Recover the signer from a v2 signature and require it to match the commitment.
The signer hash160 is committed TWICE — in the record and inside the signed statement — and both are required. 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.
Needs the chain’s genesis hash, which is why this is not part of decoding: a dependency-free decoder does not have it, and the same bytes on another chain are a different statement.
- Parameters:
record (HashMarkRecord)
network_genesis (str)
- Return type:
AttestationResult