Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,53 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **`pyrxd glyph inspect --verify-wave` names the signer.** For a VERIFIED v2
signature, resolves the WAVE names the signing key owns: hash160 -> address ->
`wave.reverse_lookup`. A recipient can then confirm both that a file matches the
recorded digest AND that it was recorded by the holder of `company.rxd`.
`pyrxd.glyph.wave.wave_names_for_hash160` is the reusable half and is not
HashMark-specific.

**Gated on verification.** An unverified or absent signature resolves nothing and
says why — presenting a name for an unproven signer would dress a claim up as an
identity, which is the failure the signature check exists to prevent.

- **HashMark v2 signatures are VERIFIED, not just reported.** `verify_attestation`
rebuilds the canonical signed statement (fixed key order, label omitted when absent,
raw UTF-8 rather than `\uXXXX` escapes), recovers the public key, and requires
`hash160(recovered) == signer`. The commitment is what makes recovery non-circular:
without a value fixed in advance, an attacker writes whatever hash their chosen
signature recovers to. Low-S is mandatory and the header is range-checked (27..34).

Attestation is kept SEPARATE from decoding, as the spec requires — an invalid
signature means the bytes were fine and the claim does not hold, which is a different
problem from a malformed record. v1 reports `not_attested` rather than a failure,
because v1 never claimed to say who.

The chain's genesis hash is part of the signed statement and is NOT carried by the
record, so the same bytes do not verify on another chain. `inspect` assumes Radiant
mainnet for a pasted script and says so.

- **`pyrxd inspect` decodes the Photonic `msg` data carrier.** `OP_RETURN PUSH3 "msg"
<push> <message>` — the only OP_RETURN format with real volume on Radiant: measured
across 20 consecutive mainnet blocks, **73 of 73** data outputs carried this marker and
nothing else did. pyrxd already WROTE these and could not read one back, so the
commonest data output on the chain rendered as opaque hex. Non-UTF-8 bytes are reported
rather than refused — they are already on chain — and display sanitisation happens at
the render boundary so the raw bytes stay recoverable for a caller verifying them.

### Fixed

- **Containers classified as `nft`/`mut` — the container branch was dead code (#578).**
`GlyphProtocol.CONTAINER` (7) is the spec'd marker and no mainnet token uses it: all
four containers on Radiant mainnet declare `type: "container"` on an ordinary NFT/MUT
protocol set. Verified on chain — the "BTC" container (reveal `57c4d660…dfb1`) decodes
to `p = (2,)` with `type = 'container'`. Three sites recognised only the protocol form:
`GlyphMetadata.is_container`, the inspect classifier, and its deliberate mirror in
`wave.py`. Both declarations now count at all three.

- **A multi-glyph reveal reported one glyph's metadata as the transaction's (#577).**
`find_reveal_metadata` returns the FIRST input carrying a decodable `gly` payload,
and that single payload was surfaced as the whole transaction's metadata. Multi-glyph
Expand Down
106 changes: 105 additions & 1 deletion src/pyrxd/cli/glyph_inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,56 @@ def _render_script_human(payload: dict) -> str:
type_ = payload.get("type", "?")
head = f"type: {type_} length: {payload['length']} bytes"
body: list[str] = []
msg = payload.get("message")
if msg:
if msg["outcome"] == "ok":
if msg["is_utf8"]:
body.append(f" message ({msg['byte_length']} bytes): {_truncate_for_human(msg['text'])}")
else:
# Say WHY there is no text rather than printing nothing, or a caller
# assumes the field is empty when the bytes simply are not text.
body.append(f" message: {msg['byte_length']} bytes, not valid UTF-8 (see data_hex)")
else:
body.append(f" message: {msg['outcome']}" + (f" — {msg['detail']}" if msg.get("detail") else ""))

hm = payload.get("hashmark")
if hm:
# HashMark is a third-party OP_RETURN format (MIT, github.com/cdonnachie/hashmark.rxd).
# Classifying it in JSON and not printing it here would leave the feature
# invisible to the person actually reading a terminal.
if hm["outcome"] == "ok":
body.append(f" HashMark v{hm['version']} ({hm['algorithm']})")
body.append(f" digest: {hm['digest']}")
if hm.get("label"):
body.append(f" label: {_truncate_for_human(hm['label'])}")
if hm.get("signer_hash160"):
body.append(f" signer: {hm['signer_hash160']}")
att = hm.get("attestation") or {}
if att.get("outcome") == "valid":
body.append(" signature VERIFIED — recovers to the committed signer")
body.append(f" (assuming {att.get('assumed_network')}; the chain is part of")
body.append(" the signed statement and a pasted script carries no context)")
wi = hm.get("wave_identity")
if wi and wi.get("resolved"):
names = wi.get("names") or []
if names:
body.append(f" WAVE identity: {', '.join(names)}")
body.append(" (the signing key owns these names — file matches the")
body.append(" digest AND was recorded by that name's holder)")
else:
body.append(" WAVE identity: none — the signing key owns no WAVE name")
elif wi:
body.append(f" WAVE identity: not resolved ({wi.get('reason')})")
elif att.get("outcome") == "invalid_signature":
# The bytes decoded; the CLAIM does not hold. Saying "malformed"
# here would send whoever is debugging it after the wrong problem.
body.append(f" signature DOES NOT VERIFY — {att.get('detail', 'no detail')}")
body.append(" (the record is well-formed; its claim is not supported)")
body.append(" (proves someone knew this digest no later than the confirming")
body.append(" block — not authorship, ownership, originality or contents)")
else:
body.append(f" HashMark: {hm['outcome']}" + (f" — {hm['detail']}" if hm.get("detail") else ""))

if type_ == "p2pkh":
body.append(f" owner_pkh: {payload['owner_pkh']}")
elif type_ in ("nft", "ft"):
Expand Down Expand Up @@ -561,8 +611,18 @@ def _render_ref_summary_body(payload: dict) -> list[str]:
default=False,
help="For an outpoint, fetch its source tx and classify the named vout.",
)
@click.option(
"--verify-wave",
"verify_wave",
is_flag=True,
default=False,
help=(
"For a VERIFIED HashMark v2 signature, look up the WAVE names the signing "
"key owns. Needs the network. Never runs on an unverified signature."
),
)
@click.pass_obj
def inspect_cmd(ctx: CliContext, inspect_input: str, fetch: bool, resolve: bool) -> None:
def inspect_cmd(ctx: CliContext, inspect_input: str, fetch: bool, resolve: bool, verify_wave: bool) -> None:
"""Classify a Glyph input.

INPUT can be:
Expand Down Expand Up @@ -689,6 +749,9 @@ def inspect_cmd(ctx: CliContext, inspect_input: str, fetch: bool, resolve: bool)
else: # pragma: no cover — _classify_input never returns other values
raise UserError(f"internal: unknown form {form!r}")

if verify_wave:
_attach_wave_identity(ctx, payload)

mode = ctx.output_mode
if mode == "json":
click.echo(emit(payload, mode="json"))
Expand Down Expand Up @@ -734,3 +797,44 @@ async def _do() -> dict:
cause=str(exc),
fix=f"check that {ctx.electrumx_url} is reachable",
) from exc


def _attach_wave_identity(ctx: CliContext, payload: dict) -> None:
"""Resolve the WAVE names a VERIFIED HashMark signer owns, and attach them.

ONLY runs on a signature that actually verified. Resolving an unverified
signer would dress a claim up as an identity — the exact failure the
signature check exists to prevent — so an unverified or absent attestation
attaches nothing and says why.

Errors are attached rather than raised: a name lookup failing is not a reason
to lose the classification the user asked for.
"""
hm = payload.get("hashmark")
if not hm:
return
att = hm.get("attestation") or {}
if att.get("outcome") != "valid":
hm["wave_identity"] = {
"resolved": False,
"reason": (
"signature did not verify; refusing to resolve an unproven signer"
if att.get("outcome") == "invalid_signature"
else "no verified v2 signature on this record"
),
}
return

from ..glyph.wave import wave_names_for_hash160

async def _do() -> list[str]:
client = ctx.make_client()
async with client:
return await wave_names_for_hash160(client, bytes.fromhex(att["recovered_hash160"]))

try:
names = asyncio.run(_do())
except Exception as exc:
hm["wave_identity"] = {"resolved": False, "reason": f"lookup failed: {exc}"}
return
hm["wave_identity"] = {"resolved": True, "names": names}
59 changes: 58 additions & 1 deletion src/pyrxd/glyph/_inspect_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@
import unicodedata

from ..hash import hash256
from ..script.hashmark import HashMarkOutcome, decode_hashmark
from ..script.hashmark import (
RADIANT_MAINNET_GENESIS,
HashMarkOutcome,
decode_hashmark,
verify_attestation,
)
from ..script.message import MessageOutcome, decode_message
from ..security.errors import ValidationError
from ..security.types import Txid
from ..transaction.transaction import Transaction
Expand Down Expand Up @@ -369,6 +375,27 @@ def _inspect_script(script_hex: str) -> dict:
# additive: anything that is not one stays plain `op_return`, because a
# scanner meets thousands of other protocols' data outputs and treating
# them as errors buries the real ones.
# The Photonic `msg` convention: OP_RETURN PUSH3 "msg" <push> <message>.
# Measured on 20 consecutive mainnet blocks, 73 of 73 OP_RETURN outputs
# carried this marker and nothing else did — it is the whole observed
# population, and pyrxd already WRITES it. Reading it back turns the
# commonest data output on the chain from an opaque blob into its text.
msg = decode_message(script)
if msg.outcome is not MessageOutcome.NOT_MESSAGE:
out["message"] = {
"outcome": msg.outcome.value,
# SANITISED here, at the display boundary. The message is arbitrary
# operator bytes and `repr` does not escape U+202E and friends; the
# decoder deliberately returns it unmangled so the raw bytes stay
# recoverable, and mangling belongs where it is shown.
"text": _sanitize_display_string(msg.text) if msg.text else None,
"is_utf8": msg.is_utf8,
"byte_length": len(msg.raw) if msg.raw else 0,
"detail": msg.detail,
}
if msg.ok:
out["type"] = "op_return-msg"

mark = decode_hashmark(script)
if mark.outcome is not HashMarkOutcome.NOT_HASHMARK:
out["hashmark"] = {
Expand All @@ -385,6 +412,18 @@ def _inspect_script(script_hex: str) -> dict:
}
if mark.ok:
out["type"] = f"op_return-hashmark-v{mark.version}"
# ATTEST, now that we can. The signed statement includes the
# chain's genesis hash — the verified context the tx was found
# in — and a pasted script carries no such context, so mainnet is
# ASSUMED and the assumption is reported rather than hidden. The
# same bytes on another chain are a different statement.
att = verify_attestation(mark, network_genesis=RADIANT_MAINNET_GENESIS)
out["hashmark"]["attestation"] = {
"outcome": att.outcome.value,
"recovered_hash160": att.recovered_hash160_hex,
"assumed_network": "radiant-mainnet",
"detail": att.detail,
}
return out

if is_nft_script(script_hex):
Expand Down Expand Up @@ -640,6 +679,24 @@ def _classify_metadata_protocol(metadata) -> str:
return "wave"
if GlyphProtocol.CONTAINER in p:
return "container"
# ...OR the `type` STRING, which is what the chain actually carries (#578).
#
# GlyphProtocol.CONTAINER (7) is the spec'd form and no mainnet token uses it.
# All four containers on Radiant mainnet declare themselves with `type:
# "container"` on an ordinary NFT/MUT protocol set, so the branch above was
# dead code and every container classified as "nft" or "mut".
#
# Verified against the chain, not inferred: the "BTC" container
# (ref 5558395540...c2ab:0, reveal 57c4d660...dfb1) decodes to `p = (2,)` with
# `type = 'container'`. The indexer agrees — it reports token_type CONTAINER
# for exactly these four and exposes no protocol field, so its label is derived
# from the same string.
#
# This is a DECLARATION, like the protocol array itself: `type` is operator CBOR
# and nothing on chain enforces it. Both forms are claims about what a token is;
# neither is a proof, and the ecosystem treats this one as the classification.
if (metadata.token_type or "").strip().lower() == "container":
return "container"
if GlyphProtocol.AUTHORITY in p:
return "authority"
if GlyphProtocol.TIMELOCK in p:
Expand Down
16 changes: 14 additions & 2 deletions src/pyrxd/glyph/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,8 +358,20 @@ class GlyphMetadata:

@property
def is_container(self) -> bool:
"""True when this envelope marks the token itself as a CONTAINER."""
return GlyphProtocol.CONTAINER in self.protocol
"""True when this envelope marks the token itself as a CONTAINER.

Either declaration counts. `GlyphProtocol.CONTAINER` (7) is the spec'd
form and NO mainnet token uses it — all four containers on Radiant
mainnet declare `type: "container"` on an ordinary NFT/MUT protocol set,
so a protocol-only test was False for every real container (#578).

Verified on chain: the "BTC" container (reveal 57c4d660...dfb1) decodes
to `p = (2,)` with `type = 'container'`.

Both are DECLARATIONS — `type` is operator CBOR and nothing on chain
enforces it, exactly as nothing enforces the protocol array.
"""
return GlyphProtocol.CONTAINER in self.protocol or (self.token_type or "").strip().lower() == "container"

def __post_init__(self) -> None:
import re
Expand Down
33 changes: 33 additions & 0 deletions src/pyrxd/glyph/wave.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Final

if TYPE_CHECKING:
from ..constants import Network

from ..security.errors import ValidationError
from .types import GlyphMetadata, GlyphProtocol

Expand Down Expand Up @@ -303,6 +306,31 @@ def from_indexer_response(cls, data: dict[str, Any]) -> WaveRecord:
raise WaveResolverError(f"could not parse indexer response: {exc}") from exc


async def wave_names_for_hash160(client, pubkey_hash160: bytes, *, network: Network | None = None) -> list[str]:
"""WAVE names owned by the address a public-key hash encodes.

The bridge between a KEY and a NAME: a hash160 is what a P2PKH address
encodes, and WAVE resolves names to addresses, so the reverse lookup answers
"which names does the holder of this key own".

Written for HashMark v2 attestation — a verified signer is a hash160, and the
question a recipient actually has is "was this recorded by company.rxd?" — but
nothing here is HashMark-specific.

CALLERS MUST ONLY PASS A KEY THEY HAVE VERIFIED. Resolving an unproven signer
would dress a claim up as an identity, which is precisely the failure the
signature check exists to prevent. See
:func:`pyrxd.script.hashmark.verify_attestation`.
"""
from ..base58 import base58check_encode
from ..constants import NETWORK_ADDRESS_PREFIX_DICT, Network

if len(pubkey_hash160) != 20:
raise ValidationError(f"pubkey_hash160 must be 20 bytes, got {len(pubkey_hash160)}")
address = base58check_encode(NETWORK_ADDRESS_PREFIX_DICT[network or Network.MAINNET] + pubkey_hash160)
return await WaveResolver(client).reverse_lookup(address)


def classify_glyph_metadata(metadata: GlyphMetadata) -> str:
"""Return the highest-specificity protocol classification for a metadata payload.

Expand Down Expand Up @@ -333,6 +361,11 @@ def classify_glyph_metadata(metadata: GlyphMetadata) -> str:
return "wave"
if GlyphProtocol.CONTAINER in p:
return "container"
# ...or the `type` STRING, which is the form the chain actually carries (#578).
# Kept in step with the deliberate mirror of this function in
# `_inspect_core._classify_metadata_protocol`; see the note there.
if (metadata.token_type or "").strip().lower() == "container":
return "container"
if GlyphProtocol.AUTHORITY in p:
return "authority"
if GlyphProtocol.TIMELOCK in p:
Expand Down
Loading
Loading