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

See pyrxd for the broader rationale on lazy public re-exports.

class pyrxd.script.BareMultisig[source]

Bases: ScriptTemplate

lock(participants, threshold)[source]
Returns:

locking script

Parameters:
Return type:

Script

unlock(private_keys)[source]
Returns:

sign (function), estimated_unlocking_byte_length (function)

Parameters:

private_keys (list[PrivateKey])

class pyrxd.script.CsvKind[source]

Bases: Enum

Relative 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 as TX_NULL_DATA only when scriptPubKey[0] is OP_RETURN (src/script/standard.cpp, Solver()); OP_FALSE OP_RETURN, which this builder emitted until 2026-09-02, falls through to TX_NONSTANDARD.

NEITHER classification has any force on Radiant, so both spellings relay. IsStandardTx is reached from exactly one place (validation.cpp:586 @ v3.1.2) and is gated on fRequireStandard, hardcoded false (validation.cpp:271) — the same fact glyph/ft.py and glyph/builder.py rest 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_RETURN data outputs, and ZERO of the OP_FALSE-prefixed form.

provably_unspendable=True restores the prefixed form. Core’s CScript::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_FALSE leaves an element on the stack, so OP_RETURN fails with ScriptError::OP_RETURN (interpreter.cpp:564-571). Bare: an empty scriptSig leaves the stack empty, and there OP_RETURN does NOT abort — it terminates evaluation as SUCCESSFUL (same lines); the spend then fails on VerifyScript’s empty-final-stack check, ScriptError::EVAL_FALSE (interpreter.cpp:3092-3094). Any non-empty scriptSig fails at the OP_RETURN itself instead.

Parameters:
Return type:

Script

unlock(**kwargs)[source]
Returns:

sign (function), estimated_unlocking_byte_length (function)

class pyrxd.script.P2PK[source]

Bases: ScriptTemplate

lock(public_key)[source]

from public key in format str or bytes

Parameters:

public_key (str | bytes)

Return type:

Script

unlock(private_key)[source]
Returns:

sign (function), estimated_unlocking_byte_length (function)

Parameters:

private_key (PrivateKey)

class pyrxd.script.P2PKH[source]

Bases: ScriptTemplate

lock(addr)[source]

from address (str) or public key hash160 (bytes)

Parameters:

addr (str | bytes)

Return type:

Script

unlock(private_key)[source]
Returns:

sign (function), estimated_unlocking_byte_length (function)

Parameters:

private_key (PrivateKey)

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_malformed is 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 where GetOp returns false and records the offset in truncated_at; it never invents a chunk. Raw bytes are preserved verbatim either way, matching CScript, which stores bytes and only fails when something walks them.

Parameters:
byte_length()[source]
Return type:

int

byte_length_varint()[source]
Return type:

bytes

classmethod find_and_delete(source, pattern)[source]
Parameters:
Return type:

Script

classmethod from_asm(asm)[source]
Parameters:

asm (str)

Return type:

Script

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 through encode_pushdata, which rewrites a non-minimal push into the minimal one — so from_chunks(Script(x).chunks) was not the identity, and find_and_delete could change a script it deleted nothing from.

Parameters:

chunks (list[ScriptChunk])

Return type:

Script

hex()[source]
Return type:

str

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 moment GetOp fails — 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_SIGPUSHONLY is applied to every connected block, so answering “yes” here for a truncated scriptSig calls valid an input no block can contain.

Return type:

bool

serialize()[source]
Return type:

bytes

size()
Return type:

int

size_varint()
Return type:

bytes

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 as GetOp fails. Rendering clean ASM for a script the node cannot read is how a malformed script gets eyeballed as fine.

Return type:

str

classmethod write_bin(octets)[source]
Parameters:

octets (bytes)

Return type:

Script

truncated_at: int | None

Byte offset of the opcode where the walk stopped, or None when the whole script parsed. Only ever set under allow_malformed.

class pyrxd.script.ScriptChunk[source]

Bases: object

A 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. data also 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.

__init__(op, data=None)[source]
Parameters:
is_ref_operand()[source]

True when op is one of the five opcodes GetScriptOp follows with a bare 36-byte operand (no length prefix).

Return type:

bool

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:

bytes

class pyrxd.script.ScriptTemplate[source]

Bases: object

abstractmethod lock(*args, **kwargs)[source]
Returns:

locking script

Return type:

Script

abstractmethod unlock(*args, **kwargs)[source]
Returns:

sign (function), estimated_unlocking_byte_length (function)

Return type:

UnlockingScriptTemplate

class pyrxd.script.Unknown[source]

Bases: ScriptTemplate

lock(**kwargs)[source]
Returns:

locking script

Return type:

Script

unlock(**kwargs)[source]
Returns:

sign (function), estimated_unlocking_byte_length (function)

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 hashlib knows, which is what makes pyrxd.hashmark_tx.digest_file() able to derive its hasher from the id rather than from a second table.

Parameters:

algorithm_id (int)

Return type:

str

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’s nSequence field.

units is the BIP-112 unit count: blocks for CsvKind.BLOCKS, or 512-second intervals for CsvKind.TIME_512_SECONDS. Must be in the range [0, 65535] (16 bits).

Parameters:
Return type:

int

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 nLockTime is at or after locktime.

locktime < 500_000_000 selects a block-height lock; values at or above LOCKTIME_THRESHOLD select 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.

Parameters:
Return type:

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 sequence has elapsed (measured from the funding-output’s confirmation). Use build_csv_sequence(units, kind) to construct sequence from 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.

Parameters:
Return type:

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.

Parameters:

label (str)

Return type:

str

pyrxd.script.decode_hashmark(script)[source]

Decode a scriptPubKey as 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 scriptPubKey committing 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 compressed flag drives BOTH the committed hash160 and 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:

bytes

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.

Parameters:

algorithm_id (int)

Return type:

int

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