Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **Container and creator claims are now VERIFIED, not just repeated (#591).** `in` and
`by` are operator-supplied CBOR — anyone can write any collection's ref into their own
token — and pyrxd decoded them and handed them on, so a wallet or marketplace reading
`metadata.container_refs` would render "part of collection X" as though it were a fact.

`pyrxd.glyph.relationships.verify_relationship_claims` checks each claim against what
the transaction actually carries, and `inspect` now shows the claim **and** the verdict
together — never the claim alone.

The check is LOCAL, needing no parent lookup, because Radiant's induction rules enforce
a subset rule: every ref in an output must be backed by an input ref, where the input
set includes the spent outpoints. So a claimed parent appearing among a transaction's
output refs proves that transaction spent it. Verified against upstream Radiant-Core at
the commit this repo vendors — and note the opcode handler alone gives the opposite
answer, saying outright that it performs no membership check.

Delegated authorization is out of scope: pyrxd has no delegate concept, so a
legitimately delegated claim reads as unbacked.
### Security

- **`pyrxd[eth]` now floors `urllib3>=2.7.0`.** pyrxd does not import urllib3; it arrives two levels
Expand Down
7 changes: 7 additions & 0 deletions scripts/refresh_radiant_core_vendor.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@
# which is the only authority on "consensus vs policy". Also the file where
# `fRequireStandard = false` is hardcoded, so the policy layer is absent here.
"validation.cpp": "src/validation.cpp",
# ReferenceParser::validateTransactionReferenceOperations — the ONLY place the
# push-ref backing rule ("every output ref must be backed by an input ref") is
# applied, and therefore the only authority on WHICH ref opcodes it covers.
# Vendored because pyrxd shipped a verifier that assumed all five operand-carrying
# opcodes were backed; two of them are not, and no test could have caught that
# while the rule's source lived outside the oracle.
"validation.h": "src/validation.h",
# MAX_SCRIPT_STACK_MEMORY_USAGE and MAX_SCRIPT_OPCODE_COST — the per-script
# resource budgets `interpreter.cpp` enforces (it references both but declares
# neither), so without this file their VALUES are uncheckable.
Expand Down
11 changes: 11 additions & 0 deletions src/pyrxd/cli/glyph_inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,17 @@ def _render_txid_human(payload: dict) -> str:
lines.append(f" desc: {_truncate_for_human(metadata['description'])}")
if metadata.get("decimals"):
lines.append(f" decimals: {metadata['decimals']}")
# The claim AND the verdict, never the claim alone (#591). Rendering
# "in collection X" without saying whether anything authorised it is the
# defect this exists to fix — the same shape as showing a WAVE name for
# an unverified HashMark signer.
for rel in metadata.get("relationships") or []:
label = "collection" if rel["kind"] == "container" else "creator"
if rel["outcome"] == "backed":
lines.append(f" {label}: {rel['ref']} [VERIFIED — spent in this tx]")
else:
lines.append(f" {label}: {rel['ref']} [CLAIMED ONLY — nothing authorised it]")

if metadata.get("main"):
lines.append(f" main: {metadata['main']}")
tl = metadata.get("timelock")
Expand Down
31 changes: 31 additions & 0 deletions src/pyrxd/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,37 @@ def genesis_hash_for(network: str) -> str | None:
# *disallow-sibling* sets instead, so they must be WALKED but not COLLECTED.
PUSH_REF_OPCODES: frozenset[int] = frozenset({0xD0, 0xD8})

# The subset whose presence in an OUTPUT is evidence the transaction SPENT that ref
# — the only ref opcodes a reader may treat as authorisation.
#
# ``ReferenceParser::validateTransactionReferenceOperations`` (``src/validation.h``)
# passes exactly three output sets to ``validatePushRefRule``, the
# every-output-ref-must-appear-among-the-input-refs check: the push set (0xd0, and
# 0xd8 which files into it), the require set (0xd1), and the singleton set (0xd8).
#
# The other two operand-carrying opcodes are NOT checked against the inputs:
# * 0xd2 OP_DISALLOWPUSHINPUTREF — ``GetPushRefs`` files it into a function-LOCAL
# ``foundDisallowedRefs``, intersects it against this script's own pushes, and
# then discards it. It reaches no out-parameter at all.
# * 0xd3 OP_DISALLOWPUSHINPUTREFSIBLING — reaches only
# ``validateDisallowedSiblingsRefRule``, which compares outputs against OTHER
# OUTPUTS. It never reads the inputs.
# A transaction whose outputs carry only those two returns true from the whole rule
# before the input loop even runs. So ANYONE can name ANY ref via 0xd2/0xd3, for the
# price of one output, without ever holding the thing they named.
#
# This distinction is why the set is separate from :data:`REF_OPERAND_OPCODES`
# (which must be WALKED, to stay in step with the opcode stream) and from
# :data:`PUSH_REF_OPCODES` (which is about the sighash's ``hashOutputHashes``, a
# third question again). Three overlapping sets, three different answers; the
# verifier in :mod:`pyrxd.glyph.relationships` originally used the widest one and
# reported forged collection membership as VERIFIED.
#
# Derived, not trusted: ``consensus_oracle.input_backed_ref_opcodes()`` recovers
# this set by following the C++ chain and ``tests/test_ref_backing_matches_consensus.py``
# asserts the two agree.
INPUT_BACKED_REF_OPCODES: frozenset[int] = frozenset({0xD0, 0xD1, 0xD8})

# The same two opcodes as plain ints, for the FIXED-LAYOUT readers that expect one
# specific opcode at one specific offset rather than testing set membership
# (``glyph/dmint/chain.py`` parses ``0xd8 <ref> 0xd0 <ref>`` positionally). Derived from
Expand Down
21 changes: 21 additions & 0 deletions src/pyrxd/glyph/_inspect_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from ..security.errors import ValidationError
from ..security.types import Txid
from ..transaction.transaction import Transaction
from .relationships import verify_relationship_claims
from .types import GlyphProtocol

# --- Length / shape constants ----------------------------------------------
Expand Down Expand Up @@ -932,6 +933,26 @@ def _classify_raw_tx(txid_hex: str, raw: bytes, *, only_vout: int | None = None,
# from one place. Sanitization strips control and bidi codepoints; it
# cannot help with a Cyrillic "С" that simply LOOKS like "C".
}
# RELATIONSHIP CLAIMS, WITH THEIR VERDICT (#591). `in` and `by` are
# operator-supplied CBOR — anyone can name any collection — so the claim is
# never surfaced without whether the transaction was authorised to carry it.
# Consensus's subset rule makes that checkable from this transaction alone:
# a ref in an output carried by one of the THREE subset-checked opcodes
# (`INPUT_BACKED_REF_OPCODES`) must be backed by an input ref, so a claimed
# parent appearing under one of those means the transaction spent it. The
# other two operand-carrying opcodes prove nothing and are discarded — see
# `output_ref_operands`.
rel = verify_relationship_claims(metadata, [bytes(o.locking_script.serialize()) for o in tx.outputs])
if rel:
metadata_payload["relationships"] = [
{
"kind": v.kind.value,
"ref": f"{v.ref.txid}:{v.ref.vout}",
"outcome": v.outcome.value,
}
for v in rel
]

# ABSENCE IS SILENCE, matching `glue.py`, which sets this key only when it has
# something to say. Emitting an empty dict unconditionally made "flagged" and
# "checked and clean" indistinguishable to a caller testing for the key — and
Expand Down
148 changes: 148 additions & 0 deletions src/pyrxd/glyph/relationships.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""Verify a glyph's CONTAINER and CREATOR claims instead of repeating them.

A glyph declares its collection and creator in CBOR — ``in`` (containers) and
``by`` (authors). Those are **operator-supplied assertions**: anyone can write any
collection's ref into their own token. Reading them out and displaying them, which
is all pyrxd did, presents an unverified claim as a fact.

The authorization is observable, and — importantly — observable from the reveal
transaction ALONE, with no extra fetching.

Radiant's consensus "induction rules"
(``ReferenceParser::validateTransactionReferenceOperations``, called from
``validation.cpp:742`` and ``:2044``) enforce a SUBSET RULE: every ref appearing in
an output must be backed by some input ref, where the input ref set is the refs
carried by the spent inputs' scripts PLUS the spent outpoints themselves. Verified
against upstream Radiant-Core at the commit this repo vendors (v3.1.2,
``45e0aa4``); the normative note there reads "every output ref is backed by some
input ref (subset rule)".

So if a claimed parent ref appears among a transaction's OUTPUT refs, consensus
already guaranteed that transaction spent the parent (or its outpoint). That is
the authorization, and checking it needs only the reveal transaction.

Note the interpreter does NOT do this: ``interpreter.cpp:1957`` says so in as many
words — "there is NO per-input membership check here ... the real enforcement
lives solely in ReferenceParser". A verifier reasoning from the opcode handler
alone would conclude the opposite.

WHAT THIS DOES NOT PROVE. That the parent's owner *approved* the membership in any
social sense, only that the transaction was authorised to carry the parent's ref —
which requires having spent it. And it proves that only for refs carried by the
three opcodes consensus subset-checks: ``OP_DISALLOWPUSHINPUTREF`` and
``OP_DISALLOWPUSHINPUTREFSIBLING`` operands are local assertions anyone may write
about any ref, and reading them as backing forges the verdict outright. Delegated authorization (a delegate token consumed
by the commit and burned by the reveal) is a separate mechanism pyrxd does not
implement at all, so a legitimately delegated claim will read UNBACKED here.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from enum import Enum

from ..constants import INPUT_BACKED_REF_OPCODES
from ..security.errors import ValidationError
from .script import iter_input_refs
from .types import GlyphRef

_log = logging.getLogger(__name__)

__all__ = [
"RelationshipKind",
"RelationshipOutcome",
"RelationshipVerdict",
"output_ref_operands",
"verify_relationship_claims",
]


class RelationshipKind(Enum):
CONTAINER = "container"
AUTHOR = "author"


class RelationshipOutcome(Enum):
#: The claimed ref appears in an output under an opcode consensus subset-checks
#: against the inputs, so the transaction provably spent it. The claim is
#: authorised. A ref named only by ``OP_DISALLOWPUSHINPUTREF``/``...SIBLING`` does
#: NOT qualify — see :func:`output_ref_operands`.
BACKED = "backed"
#: The glyph claims a parent that appears nowhere in the transaction's outputs.
#: Nothing authorised it — display it as a claim, never as a fact.
UNBACKED = "unbacked"


@dataclass(frozen=True)
class RelationshipVerdict:
kind: RelationshipKind
ref: GlyphRef
outcome: RelationshipOutcome

@property
def backed(self) -> bool:
return self.outcome is RelationshipOutcome.BACKED


def output_ref_operands(output_scripts: list[bytes]) -> set[bytes]:
"""The ref operands in these outputs that consensus REQUIRED an input to back.

Not every ref operand qualifies, and the difference is the whole point of this
function. ``ReferenceParser::validateTransactionReferenceOperations`` subset-checks
only three of the five operand-carrying opcodes against the transaction's inputs
(:data:`~pyrxd.constants.INPUT_BACKED_REF_OPCODES`). The other two —
``OP_DISALLOWPUSHINPUTREF`` and ``OP_DISALLOWPUSHINPUTREFSIBLING`` — are local
assertions about this transaction's own outputs; consensus never asks whether an
input carried them. Anyone can name any ref with them, for the price of one
output, without ever holding it. Collecting those as backing is a complete
forgery of the authorisation verdict, so they are walked and discarded.

Walking still uses :func:`iter_input_refs`, which consumes the operand of ALL five
the way consensus does — so a ref-range byte sitting inside push data is not
mistaken for an opcode, and the operand-less REFHASH opcodes (``0xd4``-``0xd7``)
advance by one byte instead of swallowing 36. Filtering happens after the walk,
never by narrowing the walk: skipping an opcode's 36 bytes would desynchronise the
parse and hand back refs that are not there.

A script that cannot be walked is SKIPPED rather than failing the whole check: a
transaction may carry outputs from other protocols, and one unparseable output
must not make an honest claim read as unbacked.
"""
operands: set[bytes] = set()
for script in output_scripts:
try:
for op, operand in iter_input_refs(script):
if op not in INPUT_BACKED_REF_OPCODES:
continue
operands.add(bytes(operand))
except Exception as exc:
# Logged, not swallowed: if a claim reads UNBACKED because an output could
# not be walked, whoever is debugging that needs to know it happened.
_log.debug("output_ref_operands: skipping unwalkable output script: %s", exc)
continue
return operands


def verify_relationship_claims(metadata, output_scripts: list[bytes]) -> list[RelationshipVerdict]:
"""Check each declared container/author ref against what the transaction carries.

Returns one verdict per CLAIM. An empty list means the glyph declared nothing —
which is not a failure and must not be rendered as one.
"""
if metadata is None:
return []
backing = output_ref_operands(output_scripts)
verdicts: list[RelationshipVerdict] = []
for kind, refs in (
(RelationshipKind.CONTAINER, getattr(metadata, "container_refs", ()) or ()),
(RelationshipKind.AUTHOR, getattr(metadata, "author_refs", ()) or ()),
):
for ref in refs:
try:
wire = ref.to_bytes()
except (ValidationError, AttributeError): # pragma: no cover - malformed ref
continue
outcome = RelationshipOutcome.BACKED if wire in backing else RelationshipOutcome.UNBACKED
verdicts.append(RelationshipVerdict(kind=kind, ref=ref, outcome=outcome))
return verdicts
Loading
Loading