Skip to content

feat: verify container and creator claims instead of repeating them (#591) - #592

Open
Zyrtnin wants to merge 2 commits into
mainfrom
feat/verify-container-and-creator-claims
Open

feat: verify container and creator claims instead of repeating them (#591)#592
Zyrtnin wants to merge 2 commits into
mainfrom
feat/verify-container-and-creator-claims

Conversation

@Zyrtnin

@Zyrtnin Zyrtnin commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Closes #591.

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. Same failure --verify-wave deliberately refuses to make for HashMark signers.

inspect now shows the claim and the verdict:

  collection: ab…ab:7  [VERIFIED — spent in this tx]
  creator:    cd…cd:1  [CLAIMED ONLY — nothing authorised it]

The check is local, and that's 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 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 we vendor (v3.1.2, 45e0aa4), because the vendored subset doesn't include validation.h.

Worth flagging: the opcode handler 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 this property doesn't hold.

The anti-forgery property

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 — plant-verified by swapping in the naive scan, which fails exactly that case and nothing else.

An unwalkable output is skipped and logged rather than failing the check: a transaction may carry other protocols' outputs, and one unparseable script must not make an honest claim read as unbacked.

Scope, stated in the module rather than left to be discovered

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.

Tests are constructed, not real: measured, zero of 600 sampled mainnet glyphs carry a relationship claim, so there's 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 locally: 11,004 passed, 192 skipped, 1 xfailed.

🤖 Generated with Claude Code

Mudwood Labs and others added 2 commits September 2, 2026 23:29
…591)

`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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@Zyrtnin

Zyrtnin commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

A three-family security panel found this verifier reported forged collection/creator membership as authentic, and the fix is now pushed.

output_ref_operands collected every 36-byte ref operand in the outputs and read presence as proof the transaction had spent that ref — rendering [VERIFIED — spent in this tx]. Two of the five operand-carrying opcodes are never checked against the inputs:

  • 0xd2 OP_DISALLOWPUSHINPUTREFGetPushRefs files it into a function-local foundDisallowedRefs, intersects it against the same 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. So anyone could mint a token naming any collection they had never touched, for the price of one output, and this branch would have called it authentic.

Why no test could have caught it: the rule lives in src/validation.h, which was not among the vendored consensus sources. The oracle had no opinion, so every test could only confirm pyrxd agreed with pyrxd. That file is now vendored, and consensus_oracle.input_backed_ref_opcodes() recovers the covered set by following the chain through the C++ rather than anyone re-typing it.

Worth recording that the fix as first prescribed to me was {0xd0, 0xd8} — which drops OP_REQUIREINPUTREF. 0xd1 is passed to validatePushRefRule against the input set, so that version would have made every honestly require-backed claim read UNBACKED. The correct set is {0xd0, 0xd1, 0xd8}, and that wrong fix is one of the four planted defects the new tests catch.

Same class, found while verifying: the vendored-source digest check was parametrized over a hand-typed list of two filenames while eight files were vendored — measured, not assumed, by appending a line to validation.h and watching the suite stay green. It is now derived from the manifest and cross-checked in both directions.

@Zyrtnin

Zyrtnin commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Re-attacked this fix rather than trusting it, since narrowing what counts as backing is exactly the shape that produces a guard refusing valid work.

Measured on mainnet — 140 blocks (461,000–461,139), 120 of which contain ref opcodes, counted by walking every output script as an opcode stream:

opcode occurrences in outputs treated as backing
0xd0 OP_PUSHINPUTREF 929 yes
0xd8 OP_PUSHINPUTREFSINGLETON 465 yes
0xd1 OP_REQUIREINPUTREF 0 yes
0xd2 OP_DISALLOWPUSHINPUTREF 0 no
0xd3 OP_DISALLOWPUSHINPUTREFSIBLING 0 no

So the two opcodes this now discards do not appear in real output scripts at all, while the two that carry all 1,394 real occurrences are both kept. No honest claim in that window changes verdict.

Two things I should state precisely rather than let the table overclaim:

  • 0xd1 is also zero in this sample, so the practical difference between {0xd0, 0xd1, 0xd8} and the {0xd0, 0xd8} originally prescribed to me is currently nil on chain. The correctness argument is unchanged — 0xd1 is passed to validatePushRefRule against the input set, so dropping it would refuse a genuinely-backed claim the day someone writes one — but no such claim exists in this window, and I don't want the earlier phrasing read as "this was breaking live tokens".
  • 140 blocks is a sample, not the chain. It is enough to say 0xd2/0xd3 are not in routine use; it is not enough to say they have never been used. The fix does not depend on that — an unbacked opcode is unbacked however often it appears — but the refuses-valid-work risk is bounded by observation here, not by proof.

Separately confirmed by reading rather than measurement: no builder in pyrxd emits 0xd2 or 0xd3 into an output at all — they appear only in parsers, comments and the inspect renderer — so pyrxd's own mints cannot be affected either way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

container and creator claims are repeated, never verified — nothing checks the parent participated in the reveal

1 participant