diff --git a/CHANGELOG.md b/CHANGELOG.md index 833aab10..6ed2ed24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scripts/refresh_radiant_core_vendor.py b/scripts/refresh_radiant_core_vendor.py index c8ede921..764bcb8d 100644 --- a/scripts/refresh_radiant_core_vendor.py +++ b/scripts/refresh_radiant_core_vendor.py @@ -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. diff --git a/src/pyrxd/cli/glyph_inspect.py b/src/pyrxd/cli/glyph_inspect.py index 8d046835..4caf94ea 100644 --- a/src/pyrxd/cli/glyph_inspect.py +++ b/src/pyrxd/cli/glyph_inspect.py @@ -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") diff --git a/src/pyrxd/constants.py b/src/pyrxd/constants.py index 2f494680..749b2143 100644 --- a/src/pyrxd/constants.py +++ b/src/pyrxd/constants.py @@ -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 0xd0 `` positionally). Derived from diff --git a/src/pyrxd/glyph/_inspect_core.py b/src/pyrxd/glyph/_inspect_core.py index 497a0aee..1397dfc9 100644 --- a/src/pyrxd/glyph/_inspect_core.py +++ b/src/pyrxd/glyph/_inspect_core.py @@ -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 ---------------------------------------------- @@ -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 diff --git a/src/pyrxd/glyph/relationships.py b/src/pyrxd/glyph/relationships.py new file mode 100644 index 00000000..8cc9863d --- /dev/null +++ b/src/pyrxd/glyph/relationships.py @@ -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 diff --git a/tests/consensus_oracle.py b/tests/consensus_oracle.py index 8814c43d..bb712246 100644 --- a/tests/consensus_oracle.py +++ b/tests/consensus_oracle.py @@ -624,6 +624,150 @@ def low_s_gate_flag_name() -> str: # --------------------------------------------------------------------------- +# Fact 12 — WHICH ref opcodes the backing-subset rule actually covers +# (script.cpp GetPushRefs -> validation.h validateTransactionReferenceOperations) +# --------------------------------------------------------------------------- +# +# "Carries a 36-byte ref operand" and "consensus guarantees the transaction spent +# that ref" are DIFFERENT QUESTIONS WITH DIFFERENT ANSWERS, and pyrxd shipped a +# verifier that conflated them: it read all five operand-carrying opcodes as proof +# of backing and stamped the result "VERIFIED — spent in this tx". Two of the five +# are never checked against the inputs at all, so anyone could name a collection +# they had never touched and be believed. +# +# The rule lives in ``validation.h``, which was NOT vendored when that verifier was +# written — so no differential could have caught it, and reasoning from the code +# under review could only ever confirm the code was self-consistent. That absence +# is the actual root cause, which is why the fix is an extractor here rather than a +# corrected constant in ``constants.py``. +# +# The chain this follows, in three hops: +# +# 1. ``GetPushRefs`` files each opcode's operand into a FUNCTION-LOCAL set +# (``foundPushRefs``, ``foundRequiredRefs``, ``foundDisallowedRefs``, ...). +# 2. At the end, only SOME of those locals are merged into the caller's +# out-parameters. ``foundDisallowedRefs`` (0xd2) is merged into NOTHING — it +# is intersected against this script's own pushes and then discarded. +# 3. ``validateTransactionReferenceOperations`` passes only SOME of the +# out-parameters to ``validatePushRefRule``, which is the actual +# output-must-be-a-subset-of-input check. The disallow-sibling set (0xd3) +# instead reaches ``validateDisallowedSiblingsRefRule``, which compares +# outputs against OTHER OUTPUTS and never reads the inputs. +# +# An opcode is backed only if its operand survives all three hops. + +#: ``pushRefSet.insert(foundPushRefs.begin(), ...)`` — hop 2, local set to out-param. +_MERGE = re.compile(r"(\w+)\s*\.insert\s*\(\s*(\w+)\s*\.begin\s*\(\s*\)") + +#: ``validatePushRefRule(inputPushRefSet, outputRequireRefSet)`` — hop 3. The SECOND +#: argument is the output-side set being required to be a subset of the inputs. +_SUBSET_CHECK = re.compile(r"validatePushRefRule\s*\(\s*\w+\s*,\s*(\w+)\s*\)") + + +def _validate_tx_refs_body() -> str: + src = _strip_comments(vendored_source("validation.h")) + start = src.find("static bool validateTransactionReferenceOperations(") + if start < 0: + raise OracleParseError("could not locate validateTransactionReferenceOperations in vendored validation.h") + end = src.find("\n static ", start + 1) + return src[start : end if end > 0 else len(src)] + + +@lru_cache(maxsize=1) +def _get_push_refs_out_params() -> list[str]: + """The out-parameter names of ``GetPushRefs``, IN ORDER. + + Order is load-bearing: the caller passes its own variables positionally, so + mapping caller name to consensus role is a positional join. Getting this + backwards would silently swap "require" and "disallow-sibling", which is the + exact confusion being guarded against. + """ + src = _strip_comments(vendored_source("script.cpp")) + sig = re.search( + r"bool\s+CScript::GetPushRefs\s*\(\s*const_iterator\s+pc\s*,(.*?)\)\s*const\s*\{", + src, + re.DOTALL, + ) + if not sig: + raise OracleParseError("could not parse the GetPushRefs signature") + params = [re.sub(r".*[\s&*]", "", p).strip() for p in sig.group(1).split(",") if p.strip()] + if "pushRefSet" not in params: + raise OracleParseError(f"GetPushRefs signature parsed to an unexpected parameter list: {params}") + return params + + +@lru_cache(maxsize=1) +def input_backed_ref_opcode_names() -> frozenset[str]: + """Opcode names whose output operand consensus REQUIRES an input to back. + + This is the set a reader may treat as authorisation. Its complement within + :func:`ref_operand_opcode_names` — currently ``OP_DISALLOWPUSHINPUTREF`` and + ``OP_DISALLOWPUSHINPUTREFSIBLING`` — may be written by anyone about anything, + at the cost of one output, and means only what the script says locally. + """ + script_src = _strip_comments(vendored_source("script.cpp")) + body = _get_push_refs_body(script_src) + + # Hop 1: opcode -> the function-local sets its branch inserts into. + branches = list(_BRANCH.finditer(body)) + if not branches: + raise OracleParseError("no `if (opcode == OP_x) {` dispatch chain found in GetPushRefs") + filed: dict[str, set[str]] = {} + for i, match in enumerate(branches): + end = branches[i + 1].start() if i + 1 < len(branches) else len(body) + filed[match.group(1)] = {m.group(1) for m in re.finditer(r"(\w+)\s*\.insert\s*\(", body[match.end() : end])} + if not any(filed.values()): + raise OracleParseError("GetPushRefs dispatch parsed to no set insertions at all") + + # Hop 2: function-local set -> out-parameter. Only merges whose TARGET is an + # out-param count; `foundDisallowedRefs` is consumed by a set_intersection into + # a local and never merged, which is precisely why 0xd2 is unbacked. + out_params = set(_get_push_refs_out_params()) + local_to_out: dict[str, str] = {m.group(2): m.group(1) for m in _MERGE.finditer(body) if m.group(1) in out_params} + if not local_to_out: + raise OracleParseError("no local-set -> out-parameter merges found at the end of GetPushRefs") + + # Hop 3: out-parameter role -> is it subset-checked against the inputs? + validate_body = _validate_tx_refs_body() + checked_caller_vars = {m.group(1) for m in _SUBSET_CHECK.finditer(validate_body)} + if not checked_caller_vars: + raise OracleParseError("no validatePushRefRule calls found in validateTransactionReferenceOperations") + + # The caller's variables are bound to consensus roles positionally, at its + # buildRefSetFromScript call over the OUTPUTS. The input-side call is skipped: + # what the inputs contribute is the thing being checked AGAINST, not checked. + call = re.search(r"buildRefSetFromScript\s*\(\s*tx\.vout\[\w+\]\.scriptPubKey\s*,(.*?)\)", validate_body, re.DOTALL) + if not call: + raise OracleParseError("could not locate the per-output buildRefSetFromScript call") + caller_args = [a.strip() for a in call.group(1).split(",") if a.strip()] + roles = _get_push_refs_out_params()[: len(caller_args)] + if len(caller_args) != len(roles): + raise OracleParseError(f"arity mismatch: {len(caller_args)} args vs {len(roles)} out-params") + arg_for_role = dict(zip(roles, caller_args, strict=True)) + + # A caller variable may be accumulated into another before the check + # (`outputPushRefSet.insert(outputPushRefSetLocal.begin(), ...)`), so follow one + # hop of aliasing rather than requiring the checked name to appear verbatim. + aliases: dict[str, str] = {m.group(2): m.group(1) for m in _MERGE.finditer(validate_body)} + backed_roles = { + role + for role, var in arg_for_role.items() + if var in checked_caller_vars or aliases.get(var) in checked_caller_vars + } + if not backed_roles: + raise OracleParseError("no output ref set reached validatePushRefRule — parse is wrong") + + backed = {op for op, locals_ in filed.items() if {local_to_out.get(s) for s in locals_} & backed_roles} + if not backed: + raise OracleParseError("backing chain parsed to an empty opcode set") + return frozenset(backed) + + +def input_backed_ref_opcodes() -> frozenset[int]: + table = opcode_table() + return frozenset(table[n] for n in input_backed_ref_opcode_names()) + + # Fact 7 — per-script resource budgets (consensus/consensus.h) # --------------------------------------------------------------------------- diff --git a/tests/test_ref_backing_matches_consensus.py b/tests/test_ref_backing_matches_consensus.py new file mode 100644 index 00000000..e9604db4 --- /dev/null +++ b/tests/test_ref_backing_matches_consensus.py @@ -0,0 +1,106 @@ +"""The set of ref opcodes pyrxd reads as authorisation must be consensus's set. + +pyrxd shipped a relationship verifier that treated all five operand-carrying ref +opcodes as proof the transaction had spent the ref, and rendered the result +``[VERIFIED — spent in this tx]``. Two of the five are never checked against the +inputs, so anyone could mint a token naming a valuable collection they had never +touched and pyrxd would call the forgery authentic. + +Nothing could have caught it. The rule lives in ``validation.h``, which was not +among the vendored consensus sources, so the differential oracle had no opinion and +every test that existed could only confirm pyrxd agreed with pyrxd. Vendoring that +file is the fix; this module is the assertion it makes possible. + +Three overlapping opcode sets exist and are easy to conflate — walk-me +(:data:`REF_OPERAND_OPCODES`), summarise-me (:data:`PUSH_REF_OPCODES`), and +trust-me (:data:`INPUT_BACKED_REF_OPCODES`). They are pinned together here so the +next person to reach for one is shown the other two. +""" + +from __future__ import annotations + +import pytest + +from pyrxd.constants import INPUT_BACKED_REF_OPCODES, PUSH_REF_OPCODES, REF_OPERAND_OPCODES +from pyrxd.glyph.relationships import output_ref_operands +from tests import consensus_oracle as oracle + +REF = bytes(range(36)) + + +def _output(opcode: int, ref: bytes = REF) -> bytes: + """One output script naming *ref* under *opcode*, then paying to a key.""" + return bytes([opcode]) + ref + b"\x75" + bytes.fromhex("76a914") + bytes(20) + bytes.fromhex("88ac") + + +class TestTheConstantIsDerivedFromConsensus: + def test_it_equals_what_the_C_source_actually_subset_checks(self) -> None: + assert oracle.input_backed_ref_opcodes() == INPUT_BACKED_REF_OPCODES + + def test_the_oracle_names_them_rather_than_agreeing_by_accident(self) -> None: + """A set comparison can pass on two empty sets. Pin the names too.""" + assert oracle.input_backed_ref_opcode_names() == { + "OP_PUSHINPUTREF", + "OP_REQUIREINPUTREF", + "OP_PUSHINPUTREFSINGLETON", + } + + def test_the_unbacked_two_are_exactly_the_disallow_opcodes(self) -> None: + table = oracle.opcode_table() + assert { + table["OP_DISALLOWPUSHINPUTREF"], + table["OP_DISALLOWPUSHINPUTREFSIBLING"], + } == REF_OPERAND_OPCODES - INPUT_BACKED_REF_OPCODES + + def test_the_three_sets_are_kept_distinct(self) -> None: + """Each is a strict answer to a different question. Any two becoming equal + means someone unified them, which is the conflation this file exists for.""" + assert PUSH_REF_OPCODES < INPUT_BACKED_REF_OPCODES < REF_OPERAND_OPCODES + + +class TestForgedAuthorisationIsNotBacking: + """The exploit: name a ref you never held, at the cost of one output.""" + + @pytest.mark.parametrize("opcode", [0xD2, 0xD3], ids=["OP_DISALLOWPUSHINPUTREF", "OP_DISALLOWPUSHINPUTREFSIBLING"]) + def test_a_ref_named_by_a_disallow_opcode_is_not_backing(self, opcode: int) -> None: + assert output_ref_operands([_output(opcode)]) == set() + + def test_a_forgery_beside_an_honest_ref_does_not_borrow_its_backing(self) -> None: + """Two outputs, one real. Only the real ref may come back.""" + other = bytes(range(100, 136)) + backing = output_ref_operands([_output(0xD0, REF), _output(0xD3, other)]) + assert backing == {REF} + + def test_both_opcodes_in_one_script_still_yield_nothing(self) -> None: + assert output_ref_operands([bytes([0xD2]) + REF + bytes([0xD3]) + REF + b"\x75"]) == set() + + +class TestHonestBackingIsStillRecognised: + """The other half. A verifier that refuses real authorisation is also broken — + it would report every genuine collection member as an unverified claim.""" + + @pytest.mark.parametrize("opcode", sorted(INPUT_BACKED_REF_OPCODES), ids=lambda o: f"0x{o:02x}") + def test_every_subset_checked_opcode_counts_as_backing(self, opcode: int) -> None: + assert output_ref_operands([_output(opcode)]) == {REF} + + def test_OP_REQUIREINPUTREF_specifically(self) -> None: + """Called out because the obvious narrowing — "only the push opcodes count" — + drops it, and 0xd1 IS passed to validatePushRefRule against the input set. + A require-ref in an output does prove an input carried it.""" + assert 0xD1 in INPUT_BACKED_REF_OPCODES + assert output_ref_operands([_output(0xD1)]) == {REF} + + +class TestFilteringDoesNotDesynchroniseTheWalk: + def test_a_discarded_opcodes_36_bytes_are_still_consumed(self) -> None: + """The tempting implementation — drop 0xd2/0xd3 from the walker's opcode set — + is wrong in a way that produces MORE backing, not less: the 36 operand bytes + would then be read as opcodes, and a 0xd0 byte inside a ref would fabricate a + ref that is not in the script. Here a real push FOLLOWS a discarded one, so a + desynchronised parse cannot land on the right answer by luck.""" + script = bytes([0xD2]) + bytes([0xD0] * 36) + bytes([0xD0]) + REF + b"\x75" + assert output_ref_operands([script]) == {REF} + + def test_a_ref_byte_inside_push_data_is_not_a_ref(self) -> None: + script = b"\x25" + bytes([0xD0]) + bytes(36) + b"\x75" + assert output_ref_operands([script]) == set() diff --git a/tests/test_relationship_claims_are_verified.py b/tests/test_relationship_claims_are_verified.py new file mode 100644 index 00000000..acd17a05 --- /dev/null +++ b/tests/test_relationship_claims_are_verified.py @@ -0,0 +1,113 @@ +"""A container/creator claim must be checked, not repeated. + +`in` and `by` are operator-supplied CBOR: anyone can write any collection's ref +into their own token. pyrxd decoded them and handed them on, so a marketplace or +wallet reading `metadata.container_refs` would render "part of collection X" — +an unverified assertion, presented as a fact. + +WHY THE CHECK IS LOCAL. Radiant's induction rules +(`ReferenceParser::validateTransactionReferenceOperations`, called from +`validation.cpp:742`) enforce a SUBSET RULE: every ref in an output must be backed +by an input ref, where the input set includes the spent outpoints themselves. + +THAT RULE COVERS THREE OF THE FIVE operand-carrying opcodes, not all five, and this +docstring originally said "every ref" and claimed the claim was verified against +upstream — while `validation.h`, the file the rule lives in, was not among the +vendored sources and could not have been checked. `OP_DISALLOWPUSHINPUTREF` reaches +no out-parameter at all and `OP_DISALLOWPUSHINPUTREFSIBLING` is compared only +against other OUTPUTS, so either can name any ref without holding it. It is now +vendored, and `tests/test_ref_backing_matches_consensus.py` derives the covered set +from it. So a claimed parent appearing among a transaction's output refs under one +of the three PROVES the transaction spent it — no parent fetch required. + +Reasoning from the opcode handler alone gives the opposite answer: +`interpreter.cpp:1957` says outright that it performs NO membership check. +""" + +from __future__ import annotations + +import pytest + +from pyrxd.glyph.relationships import ( + RelationshipKind, + RelationshipOutcome, + output_ref_operands, + verify_relationship_claims, +) +from pyrxd.glyph.types import GlyphMetadata, GlyphProtocol, GlyphRef + +_PARENT = GlyphRef(txid="ab" * 32, vout=7) +_OTHER = GlyphRef(txid="cd" * 32, vout=1) +_P2PKH = b"\x76\xa9\x14" + b"\x11" * 20 + b"\x88\xac" + + +def _singleton_carrying(ref: GlyphRef) -> bytes: + """An output whose script carries `ref` via OP_PUSHINPUTREFSINGLETON (0xd8).""" + return b"\xd8" + ref.to_bytes() + b"\x75" + _P2PKH + + +def _meta(**kw) -> GlyphMetadata: + return GlyphMetadata(protocol=(GlyphProtocol.NFT.value,), **kw) + + +class TestAClaimBackedByTheTransaction: + def test_a_container_ref_carried_in_an_output_is_verified(self) -> None: + v = verify_relationship_claims(_meta(container_refs=(_PARENT,)), [_singleton_carrying(_PARENT), _P2PKH]) + assert [x.outcome for x in v] == [RelationshipOutcome.BACKED] + assert v[0].kind is RelationshipKind.CONTAINER + + def test_an_author_ref_is_verified_the_same_way(self) -> None: + v = verify_relationship_claims(_meta(author_refs=(_PARENT,)), [_singleton_carrying(_PARENT)]) + assert v[0].kind is RelationshipKind.AUTHOR and v[0].backed + + def test_both_kinds_are_reported_separately(self) -> None: + v = verify_relationship_claims( + _meta(container_refs=(_PARENT,), author_refs=(_OTHER,)), + [_singleton_carrying(_PARENT), _singleton_carrying(_OTHER)], + ) + assert {x.kind for x in v} == {RelationshipKind.CONTAINER, RelationshipKind.AUTHOR} + assert all(x.backed for x in v) + + +class TestAnUnbackedClaim: + def test_naming_a_collection_the_tx_never_touched_is_UNBACKED(self) -> None: + """The whole point. Writing someone else's collection ref into your own + token must not read as membership.""" + v = verify_relationship_claims(_meta(container_refs=(_PARENT,)), [_P2PKH]) + assert v[0].outcome is RelationshipOutcome.UNBACKED + + def test_carrying_a_DIFFERENT_ref_does_not_back_the_claim(self) -> None: + """A transaction that legitimately carries some other token's ref must not + launder an unrelated claim.""" + v = verify_relationship_claims(_meta(container_refs=(_PARENT,)), [_singleton_carrying(_OTHER)]) + assert v[0].outcome is RelationshipOutcome.UNBACKED + + def test_one_backed_and_one_not_are_reported_independently(self) -> None: + v = verify_relationship_claims(_meta(container_refs=(_PARENT, _OTHER)), [_singleton_carrying(_PARENT)]) + assert [x.outcome for x in v] == [RelationshipOutcome.BACKED, RelationshipOutcome.UNBACKED] + + +class TestNoClaimIsNotAFailure: + @pytest.mark.parametrize("meta", [_meta(), None]) + def test_a_glyph_declaring_nothing_yields_no_verdicts(self, meta) -> None: + """Most glyphs claim nothing — measured, zero relationship claims in 600 + sampled mainnet glyphs. An empty list must never render as a failure.""" + assert verify_relationship_claims(meta, [_singleton_carrying(_PARENT)]) == [] + + +class TestTheRefWalkIsOpcodeAware: + def test_a_ref_pattern_inside_PUSH_DATA_does_not_count(self) -> None: + """The 36 bytes of a ref sitting inside push data are data, not an operand. + A naive `in` scan over the raw script would call this backed and let anyone + forge membership by embedding the bytes.""" + payload = b"\xd8" + _PARENT.to_bytes() + script = b"\x6a" + bytes([len(payload)]) + payload # OP_RETURN + assert _PARENT.to_bytes() in script, "the bytes ARE present, which is the trap" + assert output_ref_operands([script]) == set(), "but not as a ref operand" + + def test_an_unwalkable_output_does_not_fail_the_whole_check(self) -> None: + """A transaction may carry other protocols' outputs. One unparseable script + must not make an honest claim read as unbacked.""" + truncated = b"\xd8" + b"\x00" * 10 # ref opcode with a short operand + v = verify_relationship_claims(_meta(container_refs=(_PARENT,)), [truncated, _singleton_carrying(_PARENT)]) + assert v[0].backed diff --git a/tests/vendor/radiant_core/MANIFEST.json b/tests/vendor/radiant_core/MANIFEST.json index e3131c73..c5f2b431 100644 --- a/tests/vendor/radiant_core/MANIFEST.json +++ b/tests/vendor/radiant_core/MANIFEST.json @@ -13,38 +13,42 @@ "license": "MIT", "fetched_utc": "2026-09-04", "files": { - "script.h": { - "upstream_path": "src/script/script.h", - "sha256": "3de78962b07f9fbaada512a2f2f30ab70aab9742801b263fdba28a81219f36ae" + "interpreter.cpp": { + "upstream_path": "src/script/interpreter.cpp", + "sha256": "86d663c5eff9e399ea44437abf1be09dca95666a6957fcd5d989ce7367fa35c6" }, - "script.cpp": { - "upstream_path": "src/script/script.cpp", - "sha256": "759ab52423cbdff91fdfa2429d87327d7fcffa8380c52169cdd0fac70cd8f91e" + "policy.h": { + "upstream_path": "src/policy/policy.h", + "sha256": "589eaf7fe58f872713b029e6ed61337bee8e042c8d0b7ceb6cd1aac41b0e966c" }, "primitives_transaction.h": { "upstream_path": "src/primitives/transaction.h", "sha256": "5c2157e689434f684224c0aa222c7bee168be2e44e8e8f0c3f17599887ab1291" }, - "interpreter.cpp": { - "upstream_path": "src/script/interpreter.cpp", - "sha256": "86d663c5eff9e399ea44437abf1be09dca95666a6957fcd5d989ce7367fa35c6" + "script.cpp": { + "upstream_path": "src/script/script.cpp", + "sha256": "759ab52423cbdff91fdfa2429d87327d7fcffa8380c52169cdd0fac70cd8f91e" }, - "sigencoding.cpp": { - "upstream_path": "src/script/sigencoding.cpp", - "sha256": "ac309f8db47890bf62cf043ff121cb8a8ee0c70e4125c3501163198cdaea629f" + "script.h": { + "upstream_path": "src/script/script.h", + "sha256": "3de78962b07f9fbaada512a2f2f30ab70aab9742801b263fdba28a81219f36ae" }, "script_flags.h": { "upstream_path": "src/script/script_flags.h", "sha256": "cfecd8071d0196c5dc27b646d780d2c1cca938b80564313689b6ab6abe21f0ad" }, - "policy.h": { - "upstream_path": "src/policy/policy.h", - "sha256": "589eaf7fe58f872713b029e6ed61337bee8e042c8d0b7ceb6cd1aac41b0e966c" + "sigencoding.cpp": { + "upstream_path": "src/script/sigencoding.cpp", + "sha256": "ac309f8db47890bf62cf043ff121cb8a8ee0c70e4125c3501163198cdaea629f" }, "validation.cpp": { "upstream_path": "src/validation.cpp", "sha256": "c10f1c4beffd8b976a48c879415b5f58056f43550491d5be5440929e43a05c6e" }, + "validation.h": { + "upstream_path": "src/validation.h", + "sha256": "211668743e583a854a282a59722d5a51f23aafe490d8ca9cadcd347380bc3e58" + }, "consensus.h": { "upstream_path": "src/consensus/consensus.h", "sha256": "c344ba585c225420c3d333f952507357e55f7069d1e816d4fe01c16dc063f4d6" diff --git a/tests/vendor/radiant_core/validation.h b/tests/vendor/radiant_core/validation.h new file mode 100644 index 00000000..486367c2 --- /dev/null +++ b/tests/vendor/radiant_core/validation.h @@ -0,0 +1,1133 @@ +// Copyright (c) 2009-2010 Satoshi Nakamoto +// Copyright (c) 2009-2016 The Bitcoin Core developers +// Copyright (c) 2022-2026 The Radiant developers +// Copyright (c) 2017-2020 The Bitcoin developers +// Copyright (c) 2022-2026 The Radiant developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#pragma once + +#if defined(HAVE_CONFIG_H) +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include // For CMessageHeader::MessageMagic +#include