feat: verify container and creator claims instead of repeating them (#591) - #592
feat: verify container and creator claims instead of repeating them (#591)#592Zyrtnin wants to merge 2 commits into
Conversation
…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>
|
A three-family security panel found this verifier reported forged collection/creator membership as authentic, and the fix is now pushed.
A transaction whose outputs carry only those two returns Why no test could have caught it: the rule lives in Worth recording that the fix as first prescribed to me was 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 |
|
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:
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:
Separately confirmed by reading rather than measurement: no builder in pyrxd emits |
Closes #591.
inandbyare 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 readingmetadata.container_refswould render "part of collection X" as a fact. Same failure--verify-wavedeliberately refuses to make for HashMark signers.inspectnow shows the claim and the verdict:The check is local, and that's the non-obvious part
Radiant's induction rules (
ReferenceParser::validateTransactionReferenceOperations, called fromvalidation.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 includevalidation.h.Worth flagging: the opcode handler gives the opposite answer.
interpreter.cpp:1957states 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_RETURNpush.iter_input_refswalks 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