Skip to content

fix: a container declares itself with type:"container", not protocol 7 (#578) - #589

Merged
Zyrtnin merged 6 commits into
mainfrom
fix/container-declared-by-type
Sep 3, 2026
Merged

fix: a container declares itself with type:"container", not protocol 7 (#578)#589
Zyrtnin merged 6 commits into
mainfrom
fix/container-declared-by-type

Conversation

@Zyrtnin

@Zyrtnin Zyrtnin commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Closes #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 — they classified as nft or mut.

Verified on chain, not inferred. The "BTC" container (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:

site why
GlyphMetadata.is_container the property everything else reads
_classify_metadata_protocol the inspect classifier
wave.classify_glyph_metadata a deliberate mirror — 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 (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

@Zyrtnin
Zyrtnin force-pushed the fix/container-declared-by-type branch from 20f2a31 to 3c9786d Compare September 3, 2026 05:00
Mudwood Labs and others added 6 commits September 2, 2026 22:28
#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
Zyrtnin force-pushed the fix/container-declared-by-type branch from 3c9786d to 73dd454 Compare September 3, 2026 05:30
@Zyrtnin
Zyrtnin merged commit 440bf0f into main Sep 3, 2026
19 checks passed
@Zyrtnin
Zyrtnin deleted the fix/container-declared-by-type branch September 3, 2026 05:41
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.

the container classification branch is dead code: all 4 mainnet containers use type:"container", not protocol 7

1 participant