From 04a0b8dfb1c730a3f7403cd8bf56fbe67c3aefa1 Mon Sep 17 00:00:00 2001 From: Mudwood Labs Date: Wed, 2 Sep 2026 23:29:45 -0700 Subject: [PATCH 1/3] feat: verify container and creator claims instead of repeating them (#591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `in` and `by` are operator-supplied CBOR. Anyone can write any collection's ref into their own token. pyrxd decoded both and handed them on, so a wallet or marketplace reading `metadata.container_refs` would render "part of collection X" as a fact — an unverified assertion presented as a verified one, which is the same failure `--verify-wave` refuses to make for HashMark signers. THE CHECK IS LOCAL, and that is the non-obvious part. 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 ref set includes the spent OUTPOINTS themselves. So a claimed parent appearing among a transaction's output refs proves that transaction spent it — no parent fetch, no indexer. Verified against upstream Radiant-Core at the exact commit this repo vendors (v3.1.2, 45e0aa4), because the vendored subset does not include validation.h. The opcode handler alone gives the OPPOSITE answer: interpreter.cpp:1957 states outright that it performs no per-input membership check and that enforcement "lives solely in ReferenceParser". A verifier built by reading the handler would have concluded the property does not hold. The anti-forgery property is the one that matters and is plant-verified: a naive byte scan for the 36-byte ref would let anyone forge membership by embedding those bytes inside an OP_RETURN push. `iter_input_refs` walks the script as an opcode stream the way consensus does, so data is never mistaken for an operand. Planting the naive scan fails exactly that case. An unwalkable output is skipped and logged rather than failing the whole check — a transaction may carry other protocols' outputs, and one unparseable script must not make an honest claim read as unbacked. Scope: this proves the transaction was AUTHORISED to carry the parent's ref, which required spending it. It does not model delegated authorization (a delegate token consumed by the commit and burned by the reveal); pyrxd has no delegate concept, so a legitimately delegated claim reads UNBACKED. Said in the module docstring rather than left for someone to discover. Tests are constructed rather than real: measured, ZERO of 600 sampled mainnet glyphs carry a relationship claim, so there is no live vector to pin yet. The Pyodide module budget moves 35 -> 36 for one deliberate module, with the reason recorded — that number exists to catch an __init__ re-eagering a re-export, and a budget nudged up silently stops catching it. CI-equivalent: 11,004 passed, 192 skipped, 1 xfailed. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 21 +++ src/pyrxd/cli/glyph_inspect.py | 11 ++ src/pyrxd/glyph/_inspect_core.py | 18 +++ src/pyrxd/glyph/relationships.py | 128 ++++++++++++++++++ .../test_relationship_claims_are_verified.py | 106 +++++++++++++++ .../web/test_inspect_imports_pyodide_clean.py | 7 +- 6 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 src/pyrxd/glyph/relationships.py create mode 100644 tests/test_relationship_claims_are_verified.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b350a295..271bacab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ 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. + ### Fixed - **Radiant-chain reserves were converted with the BITCOIN interval at seven sites (#579).** diff --git a/src/pyrxd/cli/glyph_inspect.py b/src/pyrxd/cli/glyph_inspect.py index d0e2e82e..656d76e9 100644 --- a/src/pyrxd/cli/glyph_inspect.py +++ b/src/pyrxd/cli/glyph_inspect.py @@ -282,6 +282,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/glyph/_inspect_core.py b/src/pyrxd/glyph/_inspect_core.py index 61cbc7f0..0f2a9993 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 ---------------------------------------------- @@ -842,6 +843,23 @@ def _classify_raw_tx(txid_hex: str, raw: bytes, *, only_vout: int | None = None) "description": _sanitize_display_string(metadata.description) if metadata.description else "", "decimals": metadata.decimals, } + # 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 must be backed by an input ref, so a claimed parent + # appearing here means the transaction spent it. + 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 + ] + # TIMELOCK: say WHEN it opens, not just that it is one (#556). `classification` already # reported "timelock"; the field that answers the holder's actual question — can I read # this yet — was decoded nowhere until now. diff --git a/src/pyrxd/glyph/relationships.py b/src/pyrxd/glyph/relationships.py new file mode 100644 index 00000000..eb36577f --- /dev/null +++ b/src/pyrxd/glyph/relationships.py @@ -0,0 +1,128 @@ +"""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. 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 ..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 among the transaction's output refs, so consensus + #: required the transaction to have spent it. The claim is authorised. + 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]: + """Every 36-byte ref operand carried by these output scripts. + + Uses :func:`iter_input_refs`, which walks the script as an opcode stream 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. + + 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): + 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/test_relationship_claims_are_verified.py b/tests/test_relationship_claims_are_verified.py new file mode 100644 index 00000000..c00c1709 --- /dev/null +++ b/tests/test_relationship_claims_are_verified.py @@ -0,0 +1,106 @@ +"""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. +Verified against upstream Radiant-Core at the commit this repo vendors (v3.1.2, +`45e0aa4`). So a claimed parent appearing among a transaction's OUTPUT refs 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/web/test_inspect_imports_pyodide_clean.py b/tests/web/test_inspect_imports_pyodide_clean.py index 0cfdcf73..1e85e3f5 100644 --- a/tests/web/test_inspect_imports_pyodide_clean.py +++ b/tests/web/test_inspect_imports_pyodide_clean.py @@ -43,7 +43,12 @@ # allow some growth headroom but a sudden jump means a re-eagered # import somewhere upstream. Adjust deliberately when the count # legitimately changes (and document why in the commit). -_PYRXD_MODULE_BUDGET = 35 +# 36 since #591 added `pyrxd.glyph.relationships` — ONE deliberate module the +# inspect path genuinely needs, not an accidental re-export. Raise this only for a +# module you meant to add, and say which: the number exists to catch an `__init__.py` +# re-eagering a top-level import, and a budget nudged up without a reason stops +# catching that. +_PYRXD_MODULE_BUDGET = 36 def _clear_relevant_modules() -> None: From 9c07280566f8fad7d611c5143e9c75da282bb543 Mon Sep 17 00:00:00 2001 From: Mudwood Labs Date: Thu, 3 Sep 2026 00:56:33 -0700 Subject: [PATCH 2/3] count only the ref opcodes consensus actually backs (#591 verifier) The verifier added in this PR reported forged collection and creator membership as authentic. It collected every 36-byte ref operand in the transaction's outputs and treated presence as proof the transaction had spent that ref, then rendered the result "[VERIFIED - spent in this tx]". Two of the five operand-carrying opcodes are never checked against the inputs, so anyone could mint a token naming any valuable collection they had never touched, for the price of one output, and pyrxd would call the forgery authentic. A marketplace gating a verified-collection badge on outcome == "backed" would show a counterfeit as genuine - the exact harm #591 exists to prevent, now carrying a VERIFIED stamp. Three of the five ARE backed. ReferenceParser::validateTransactionReferenceOperations 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 are not: * 0xd2 OP_DISALLOWPUSHINPUTREF - GetPushRefs files it into a function-LOCAL foundDisallowedRefs, intersects it against this script's own pushes, and discards it. It reaches no out-parameter at all. * 0xd3 OP_DISALLOWPUSHINPUTREFSIBLING - reaches only validateDisallowedSiblingsRefRule, which compares outputs against OTHER OUTPUTS and never reads the inputs. A transaction whose outputs carry only those two returns true from the whole rule before the input loop even runs. WHY NO TEST COULD HAVE CAUGHT THIS. That rule lives in src/validation.h, which was not among the vendored consensus sources. The differential oracle had no opinion on it, so every test that existed could only confirm pyrxd agreed with pyrxd, and a reviewer reading the code under review could only confirm it was self-consistent. Vendoring the file is the fix; the constant is the consequence. consensus_oracle.input_backed_ref_opcodes() now recovers the set by following the three-hop chain through the C++ - opcode to local set, local set to out-parameter, out-parameter to validatePushRefRule - and the new test asserts INPUT_BACKED_REF_OPCODES equals it. Nobody re-types this set. Filtering happens AFTER the walk, never by narrowing it. Dropping 0xd2/0xd3 from the walker's opcode set is wrong in a way that produces MORE backing, not less: their 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. Pinned by a test where a real push follows a discarded one, so a desynchronised parse cannot land on the right answer by luck. Also fixed, same class, found while verifying this: the vendored-source digest check was parametrized over a HAND-TYPED list of two filenames while eight files were vendored. Six consensus sources could be edited with the suite green - including, once added, the one holding this very rule. Measured, not assumed: appending a line to validation.h changed nothing. The list is now derived from MANIFEST.json and cross-checked against the directory in both directions, since a file with no manifest entry is unpinned and a manifest entry with no file is a check that silently stopped running. Three docstrings asserting the over-broad premise are corrected, including one in the tests that claimed the rule had been "verified against upstream Radiant-Core at the commit this repo vendors" while the file defining it was not vendored. Verified by planting four defects, each caught: removing the filter (5 fail), narrowing the set to {0xd0, 0xd8} (4 fail), tampering with validation.h, and vendoring a file with no manifest entry. That second plant is the fix as it was first prescribed to me - it drops OP_REQUIREINPUTREF, which IS subset-checked, and would have made every honestly require-backed claim read UNBACKED. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/refresh_radiant_core_vendor.py | 7 + src/pyrxd/constants.py | 31 + src/pyrxd/glyph/_inspect_core.py | 7 +- src/pyrxd/glyph/relationships.py | 40 +- tests/consensus_oracle.py | 145 +++ tests/test_consensus_opcode_parity.py | 19 +- tests/test_ref_backing_matches_consensus.py | 106 ++ .../test_relationship_claims_are_verified.py | 13 +- tests/vendor/radiant_core/MANIFEST.json | 34 +- tests/vendor/radiant_core/validation.h | 1133 +++++++++++++++++ 10 files changed, 1504 insertions(+), 31 deletions(-) create mode 100644 tests/test_ref_backing_matches_consensus.py create mode 100644 tests/vendor/radiant_core/validation.h diff --git a/scripts/refresh_radiant_core_vendor.py b/scripts/refresh_radiant_core_vendor.py index aa4b8df6..8971f197 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", } diff --git a/src/pyrxd/constants.py b/src/pyrxd/constants.py index 49f27145..8d340b27 100644 --- a/src/pyrxd/constants.py +++ b/src/pyrxd/constants.py @@ -569,6 +569,37 @@ class OpCode(bytes, Enum): # *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 0f2a9993..48af199e 100644 --- a/src/pyrxd/glyph/_inspect_core.py +++ b/src/pyrxd/glyph/_inspect_core.py @@ -847,8 +847,11 @@ def _classify_raw_tx(txid_hex: str, raw: bytes, *, only_vout: int | None = None) # 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 must be backed by an input ref, so a claimed parent - # appearing here means the transaction spent it. + # 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"] = [ diff --git a/src/pyrxd/glyph/relationships.py b/src/pyrxd/glyph/relationships.py index eb36577f..8cc9863d 100644 --- a/src/pyrxd/glyph/relationships.py +++ b/src/pyrxd/glyph/relationships.py @@ -28,7 +28,10 @@ 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. Delegated authorization (a delegate token consumed +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. """ @@ -39,6 +42,7 @@ 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 @@ -60,8 +64,10 @@ class RelationshipKind(Enum): class RelationshipOutcome(Enum): - #: The claimed ref appears among the transaction's output refs, so consensus - #: required the transaction to have spent it. The claim is authorised. + #: 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. @@ -80,12 +86,24 @@ def backed(self) -> bool: def output_ref_operands(output_scripts: list[bytes]) -> set[bytes]: - """Every 36-byte ref operand carried by these output scripts. - - Uses :func:`iter_input_refs`, which walks the script as an opcode stream 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. + """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 @@ -94,7 +112,9 @@ def output_ref_operands(output_scripts: list[bytes]) -> set[bytes]: operands: set[bytes] = set() for script in output_scripts: try: - for _op, operand in iter_input_refs(script): + 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 diff --git a/tests/consensus_oracle.py b/tests/consensus_oracle.py index ac844757..a5408ee9 100644 --- a/tests/consensus_oracle.py +++ b/tests/consensus_oracle.py @@ -618,3 +618,148 @@ def low_s_gate_flag_name() -> str: if not match: raise OracleParseError("could not locate the low-S gate in sigencoding.cpp") return match.group(1) + + +# --------------------------------------------------------------------------- +# 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()) diff --git a/tests/test_consensus_opcode_parity.py b/tests/test_consensus_opcode_parity.py index 659ff38d..cc73cee3 100644 --- a/tests/test_consensus_opcode_parity.py +++ b/tests/test_consensus_opcode_parity.py @@ -43,6 +43,7 @@ OpCode, ) from tests.consensus_oracle import ( + VENDOR_DIR, manifest, max_opcode, opcode_table, @@ -94,7 +95,7 @@ class TestOracleIntegrity: """Guard the oracle. Each of these failing means the differentials below are meaningless, so they must fail loudly rather than degrade.""" - @pytest.mark.parametrize("name", ["script.h", "script.cpp"]) + @pytest.mark.parametrize("name", sorted(manifest()["files"])) def test_vendored_sources_match_manifest_digest(self, name): expected = manifest()["files"][name]["sha256"] assert vendored_digest(name) == expected, ( @@ -104,6 +105,22 @@ def test_vendored_sources_match_manifest_digest(self, name): f"scripts/refresh_radiant_core_vendor.py to update them and the manifest together." ) + def test_every_vendored_file_is_covered_in_BOTH_directions(self): + """The digest check above was a hand-typed list of two while eight files were + vendored, so six consensus sources — including the one holding the ref-backing + rule — could be edited with the suite still green. Verified by tampering: an + appended line to validation.h changed nothing until this was derived. + + Both directions matter. A file on disk with no manifest entry is unpinned; a + manifest entry with no file is a check that silently stopped running.""" + on_disk = {p.name for p in VENDOR_DIR.iterdir() if p.suffix in {".h", ".cpp"}} + in_manifest = set(manifest()["files"]) + assert on_disk == in_manifest, ( + f"vendored-source coverage has drifted: on disk only {sorted(on_disk - in_manifest)}, " + f"in manifest only {sorted(in_manifest - on_disk)}. Re-run " + f"scripts/refresh_radiant_core_vendor.py rather than editing either by hand." + ) + def test_opcode_table_parsed_plausibly(self): table = opcode_table() assert len(table) > 150, f"only {len(table)} enumerators parsed from `enum opcodetype`" 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 index c00c1709..acd17a05 100644 --- a/tests/test_relationship_claims_are_verified.py +++ b/tests/test_relationship_claims_are_verified.py @@ -9,9 +9,16 @@ (`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. -Verified against upstream Radiant-Core at the commit this repo vendors (v3.1.2, -`45e0aa4`). So a claimed parent appearing among a transaction's OUTPUT refs proves -the transaction spent it — no parent fetch required. + +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. diff --git a/tests/vendor/radiant_core/MANIFEST.json b/tests/vendor/radiant_core/MANIFEST.json index f5c389de..4cf4d945 100644 --- a/tests/vendor/radiant_core/MANIFEST.json +++ b/tests/vendor/radiant_core/MANIFEST.json @@ -13,37 +13,41 @@ "license": "MIT", "fetched_utc": "2026-08-11", "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" } } } 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