fix: a container declares itself with type:"container", not protocol 7 (#578) - #589
Merged
Conversation
Zyrtnin
force-pushed
the
fix/container-declared-by-type
branch
from
September 3, 2026 05:00
20f2a31 to
3c9786d
Compare
#578) GlyphProtocol.CONTAINER (7) is the spec'd marker and NO mainnet token uses it. All four containers on Radiant mainnet carry `type: "container"` on an ordinary NFT/MUT protocol set, so every protocol-only test was False for every real container and they classified as "nft" or "mut". Verified against the chain rather than inferred: the "BTC" container (ref 5558395540...c2ab:0) resolves via the indexer to reveal 57c4d660...dfb1, whose CBOR decodes to `p = (2,)` with `type = 'container'`. The indexer labels exactly these four CONTAINER and exposes no protocol field, so its own label comes from the same string. THREE SITES, not one — the branch stayed dead partly because the logic is triplicated: `GlyphMetadata.is_container`, `_classify_metadata_protocol`, and its deliberate mirror `wave.classify_glyph_metadata` (duplicated because wave.py is not import-pure). Fixing one copy would have left containers misclassified on the other two paths, so the new suite plants each site independently and confirms 4, 5 and 6 cases fail respectively. Both forms are DECLARATIONS: `type` is operator-supplied CBOR and nothing on chain enforces it, exactly as nothing enforces the protocol array. Neither is a proof of containment; the ecosystem treats this one as the classification. Paired negative cases keep it from over-reaching — a plain NFT and a token with `type: "object"` must NOT classify as containers. CI-equivalent: 10,939 passed, 192 skipped, 1 xfailed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The decoder classified a HashMark and set `type: op_return-hashmark-v1`, and
`pyrxd glyph inspect` printed exactly that one line. Every decoded field —
digest, label, signer — was in the JSON and invisible to the person reading a
terminal, which is where this renderer is read. A capability that computes
correctly and reaches nobody is not finished.
Prints the version, algorithm, digest, label and (for v2) the signer. The
signature line says NOT VERIFIED explicitly, because checking it needs secp256k1
and the chain the tx was found on: decoding is not attestation, and a record that
decodes is well-formed rather than believed.
Also prints the spec's own caveat — a mark proves someone knew the digest no
later than the confirming block, and does NOT establish authorship, ownership,
originality or the truth of the contents. The HashMark spec makes that normative
for any UI built on it, and a classifier that surfaces a digest without it invites
precisely the wrong reading.
A malformed record renders its typed outcome ("invalid — sha256 digest is 16
bytes, expected 32") instead of being silently dropped or claimed as valid.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…TURNs `OP_RETURN PUSH3 "msg" <push> <message>`. pyrxd already WROTE these (the dMint miner's op_return_msg, bounded by MAX_OP_RETURN_MSG_BYTES) and could not read one back, so the commonest data output on the chain rendered as opaque hex. MEASURED, which is why this is worth more than the HashMark decoder it follows: across 20 consecutive mainnet blocks, 73 of 73 OP_RETURN outputs carried this marker and nothing else did. HashMark currently has zero records on chain. Also factors the push walk both decoders need into `script.data_pushes_after_op_return` rather than adding a second copy — the encoder for this format is already duplicated at two sites in miner.py, and a third copy of the PARSING side is how that class starts. Non-UTF-8 is REPORTED, not refused: the bytes are already on chain, nothing constrains them to text, and refusing loses a record we can otherwise describe exactly. The decoder returns them unmangled and the DISPLAY boundary sanitises, so a caller verifying a commitment still has the bytes. The suite's primary fixture is a real mainnet output copied byte-for-byte — "Radiate " + U+1F31E + a NUL + "33" — chosen because it is not the clean case anyone would invent. A fixture holding only tidy ASCII cannot show whether the display path handles what the chain actually contains; this one pins that the NUL is sanitised for display and preserved in the raw bytes. Additive: a non-msg data output classifies exactly as before with no `message` key, and a HashMark record is not claimed as a message. Plant-verified. CI-equivalent: 10,953 passed, 192 skipped, 1 xfailed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`inspect` reported a v2 signer and said the signature was NOT VERIFIED. That left the useful half undone: the signer field alone is a CLAIM — anyone can put someone else's hash160 in a record. `verify_attestation` closes it. Rebuilds the canonical statement per §5.6 (fixed key order, no insignificant whitespace, label OMITTED when absent rather than sent empty, quote and backslash escaped and everything else raw UTF-8), takes SHA256d over the Bitcoin-signed-message preimage, recovers the public key, and requires hash160(recovered) == the committed signer. The double commitment is the point: the signer is fixed in the record AND inside the signed statement. Without a value fixed in advance, recovery is circular and proves nothing — an attacker would write whatever hash their chosen signature recovers to. Tested directly: a record signed by an attacker but carrying a victim's hash160 does not attest. Almost none of this is new code. pyrxd already had `text_digest` (exactly the spec's CompactSize-prefixed preimage), `recover_public_key`, and the same `27 + rec_id + 4-if-compressed` header convention. HashMark v2 signatures ARE Bitcoin message signatures, so the work was the statement construction, not the crypto. Deliberately NOT json.dumps for the statement: the stdlib escapes non-ASCII to \\uXXXX by default, which would change the signed bytes for any label with an accent or an emoji. Pinned by a test signing a label containing both. ATTESTATION IS SEPARATE FROM DECODING, as the spec insists. An invalid signature means the bytes were fine and the claim does not hold — calling that malformed sends whoever is debugging it after the wrong problem. v1 returns NOT_ATTESTED rather than a failure: it never claimed to say who, only when. The genesis hash is in the signed statement and NOT in the record, so the same bytes are a different statement on another chain and do not verify there. That is intended, and it is why the CLI names the chain it assumed for a pasted script. Low-S mandatory, header range-checked 27..34, r and s range-checked. Every tampering case tested — digest, label, signer, chain, malleated s, bad header — each paired with an honest-path case so the guard cannot creep into refusing valid work. CI-equivalent: 10,967 passed, 192 skipped, 1 xfailed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A HashMark v2 signer is a hash160, which is what a P2PKH address encodes, and WAVE resolves names to addresses. So a verified signature can answer the question a recipient actually has: was this recorded by the holder of company.rxd? `wave_names_for_hash160` is the reusable half — hash160 -> address -> names — and is not HashMark-specific. THE GATE MATTERS MORE THAN THE LOOKUP. It only runs on a signature that actually verified. Resolving an unproven signer would dress a claim up as an identity, and anyone can put someone else's hash160 in a record — that is precisely the failure the signature check exists to prevent. An unverified or absent attestation resolves nothing, makes no network call, and reports a reason the operator can read. Plant-verified by opening the gate: 3 cases fail. A lookup failure attaches an error rather than raising, so a name resolution being down does not lose the classification the user asked for. The test fake sits at the REAL seam. WaveResolver wraps anything that is not already an RxinDexerClient in one, so faking `wave_reverse_lookup` would bypass the wrapper production goes through; the fake implements `call_extension` instead and the assertion checks the derived ADDRESS, not just the returned names. CI-equivalent: 10,976 passed, 192 skipped, 1 xfailed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three HashMark records exist on Radiant mainnet, written by the protocol's author testing ahead of launch. Found by scanning 10,000 blocks (heights 451,021-461,021, roughly 2026-07-30 to 2026-09-03) for the HASHMARK magic. THIS IS THE THING OUR OWN CONFORMANCE VECTORS CANNOT BE. Ours are generated from our own builders, so they can only prove pyrxd agrees with pyrxd — which is how we once published vectors that ACCEPTED an exploitable HTLC timelock ordering and rejected the correct one. These bytes were produced by a different implementation, by a different author, from the same specification. The v2 record's signature was produced by his signer and verifies here against a decoder written from HASHMARK_PROTOCOL.md alone, without reading his source. The recovered key encodes 14XmXG3dSBWZUukGT3xzS9zxpiZ53vgx1i, independently confirmed as the creator address on his own Canon profile glyph — so these are his records and we recover his actual key. Also pinned: the same bytes must NOT verify against another chain's genesis (the network field is in the signed statement and not in the record), and the v1 records report NOT_ATTESTED rather than a failure. A NOTE ON HOW THIS WAS VERIFIED, because it nearly went wrong. The first plant/restore cycle reported the restore as still failing while the source was byte-identical to the commit. The cause was a stale __pycache__: the plant edited "HashMark/v2" to "HashMark/v1", a SAME-LENGTH edit, which defeats timestamp-based invalidation — the exact hazard the repo's own guidance names. Both the plant and the restore are now run with caches cleared, and the restored tree is confirmed against git rather than by eye. CI-equivalent: 10,984 passed, 192 skipped, 1 xfailed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Zyrtnin
force-pushed
the
fix/container-declared-by-type
branch
from
September 3, 2026 05:30
3c9786d to
73dd454
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #578.
GlyphProtocol.CONTAINER(7) is the spec'd marker and no mainnet token uses it. All four containers on Radiant mainnet carrytype: "container"on an ordinary NFT/MUT protocol set, so every protocol-only test was False for every real container — they classified asnftormut.Verified on chain, not inferred. The "BTC" container (
5558395540…c2ab:0) resolves via the indexer to reveal57c4d660…dfb1, whose CBOR decodes top = (2,)withtype = 'container'. The indexer labels exactly these four CONTAINER and exposes no protocol field, so its own label comes from the same string.Three sites, not one
The branch stayed dead partly because the logic is triplicated:
GlyphMetadata.is_container_classify_metadata_protocolwave.classify_glyph_metadatawave.pyis not import-pureFixing one copy would have left containers misclassified on the other two paths, so the new suite plants each site independently and confirms 4, 5 and 6 cases fail respectively.
Both forms are declarations
typeis operator-supplied CBOR and nothing on chain enforces it — exactly as nothing enforces the protocol array. Neither is a proof of containment; the ecosystem (indexer, third-party verifiers) treats this one as the classification, and pyrxd now agrees.Paired negative cases keep it from over-reaching: a plain NFT, and a token with
type: "object", must not classify as containers.CI-equivalent locally: 10,939 passed, 192 skipped, 1 xfailed.
🤖 Generated with Claude Code