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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixed

- **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
reveals are real on mainnet — one observed reveal mints **35 refs from 36 inputs** —
so thirty-four refs were shown another token's name, description and media.

Additive, because `inspect --json` has consumers: `metadata` still carries the first
payload and its `input_index`, and now also `of_n_payloads` when there is more than
one. A new `metadata_inputs` lists every input that carries a payload. The CLI says
"1 of N glyphs minted here" instead of a bare "Reveal metadata", and prints the
others — a JSON key nobody renders does not reach the person reading a terminal.

- **pyrxd silently refused about a quarter of live mainnet glyph payloads (#576).**
Two write-side limits fired on the READ path and each discarded the entire token:
a 100,000-byte cap in `GlyphMedia.__post_init__`, and `_cbor_str` raising on a
Expand Down
27 changes: 26 additions & 1 deletion src/pyrxd/cli/glyph_inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,18 @@ def _render_txid_human(payload: dict) -> str:
metadata = payload.get("metadata")
if metadata is not None:
lines.append("")
lines.append(f"Reveal metadata (from input {metadata['input_index']}):")
# SAY WHEN IT IS ONE OF SEVERAL (#577). A multi-glyph reveal carries a
# payload per minted glyph; printing one under a bare "Reveal metadata"
# heading told the reader it described the transaction. One observed
# mainnet reveal mints 35 refs from 36 inputs.
n_payloads = metadata.get("of_n_payloads")
if n_payloads:
lines.append(
f"Reveal metadata (from input {metadata['input_index']} — "
f"1 of {n_payloads} glyphs minted here; see metadata_inputs for the rest):"
)
else:
lines.append(f"Reveal metadata (from input {metadata['input_index']}):")
lines.append(f" protocol: {metadata['protocol']}")
if metadata.get("name"):
lines.append(f" name: {_truncate_for_human(metadata['name'])}")
Expand All @@ -286,6 +297,20 @@ def _render_txid_human(payload: dict) -> str:
# and for mode="block" it would be meaningless.
lines.append(" (unlocked? pass this token's metadata and your chain tip to")
lines.append(" pyrxd.is_unlocked / pyrxd.get_unlock_remaining)")
# THE OTHER GLYPHS IN A MULTI-GLYPH REVEAL (#577). Pointing at a JSON key is
# no use to someone reading the terminal, which is where this renderer is read.
others = [
row
for row in (payload.get("metadata_inputs") or [])
if row["input_index"] != (metadata or {}).get("input_index")
]
if others:
lines.append("")
lines.append(f"Other glyphs minted in this transaction ({len(others)}):")
for row in others:
label = _truncate_for_human(row["name"] or row["ticker"] or "(unnamed)")
lines.append(f" input {row['input_index']:>3}: {row['classification']:<12} {label}")

# dMint mint-claim scriptSig (vin[0] only). 4 canonical pushes:
# nonce, SHA256d(funding_script), SHA256d(OP_RETURN_script), OP_0.
# V1 = 4-byte nonce / 72-byte scriptSig; V2 = 8-byte / 76-byte.
Expand Down
31 changes: 31 additions & 0 deletions src/pyrxd/glyph/_inspect_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -811,6 +811,36 @@ def _classify_raw_tx(txid_hex: str, raw: bytes, *, only_vout: int | None = None)
f"sha256={sha256(metadata.main.data).hex()}>"
)

# EVERY input's payload, not just the first (#577).
#
# `find_reveal_metadata` returns the FIRST decodable scriptSig and that single
# payload was reported as the transaction's metadata. Multi-glyph reveals are
# real and not rare on mainnet — one observed reveal mints 35 refs from 36
# inputs — so 34 of those refs were being shown another token's name,
# description and media.
#
# The full per-input payload is not duplicated here; each entry carries enough
# to see WHICH input a name belongs to, and `metadata.input_index` already says
# which one the headline payload came from. What was missing was any signal
# that other payloads existed at all.
metadata_inputs: list[dict] = []
for idx, ss in enumerate(scriptsigs):
m = inspector.extract_reveal_metadata(ss)
if m is None:
continue
metadata_inputs.append(
{
"input_index": idx,
"classification": _classify_metadata_protocol(m),
"name": _sanitize_display_string(m.name) if m.name else "",
"ticker": _sanitize_display_string(m.ticker) if m.ticker else "",
}
)
if metadata_payload is not None and len(metadata_inputs) > 1:
# Say it on the headline payload too. A caller reading only `metadata` must
# not be able to mistake one glyph's fields for the transaction's.
metadata_payload["of_n_payloads"] = len(metadata_inputs)

return {
"form": "txid",
"txid": str(txid),
Expand All @@ -819,5 +849,6 @@ def _classify_raw_tx(txid_hex: str, raw: bytes, *, only_vout: int | None = None)
"output_count": len(tx.outputs),
"outputs": output_rows,
"metadata": metadata_payload,
"metadata_inputs": metadata_inputs,
"mint_scriptsig": mint_scriptsig,
}
112 changes: 112 additions & 0 deletions tests/test_multi_glyph_reveal_attribution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""A multi-glyph reveal must not report one glyph's metadata as the transaction's.

`GlyphInspector.find_reveal_metadata` returns the FIRST input whose scriptSig
carries a decodable `gly` payload, and that single payload was reported as the
whole transaction's metadata.

Multi-glyph reveals are real and not rare on Radiant mainnet. One observed reveal
mints **35 refs from 36 inputs**; another mints 5 from 7. In the first case
thirty-four refs were being shown a different token's name, description and media.

The fix is additive rather than a shape change, because `inspect --json` has
consumers: `metadata` still carries the first payload and the `input_index` it
came from, and now also `of_n_payloads` when there is more than one, alongside a
new `metadata_inputs` listing every input that carries a payload.
"""

from __future__ import annotations

import cbor2

from pyrxd.glyph._inspect_core import _classify_raw_tx
from pyrxd.keys import PrivateKey
from pyrxd.script.script import Script
from pyrxd.transaction.transaction import Transaction
from pyrxd.transaction.transaction_input import TransactionInput
from pyrxd.transaction.transaction_output import TransactionOutput


def _gly_scriptsig(name: str, ticker: str = "") -> bytes:
"""A reveal scriptSig: <sig> <pubkey> "gly" <CBOR>, as the chain carries it."""
body: dict = {"p": [2], "name": name}
if ticker:
body["ticker"] = ticker
cbor = cbor2.dumps(body)

def push(b: bytes) -> bytes:
if len(b) <= 0x4B:
return bytes([len(b)]) + b
if len(b) <= 0xFF:
return b"\x4c" + bytes([len(b)]) + b
return b"\x4d" + len(b).to_bytes(2, "little") + b

pub = PrivateKey().public_key().serialize()
return push(b"\x30" * 71) + push(pub) + push(b"gly") + push(cbor)


def _tx_with(scriptsigs: list[bytes]) -> bytes:
tx = Transaction(
tx_inputs=[
TransactionInput(
source_txid="ab" * 32,
source_output_index=i,
unlocking_script=Script(ss),
)
for i, ss in enumerate(scriptsigs)
],
tx_outputs=[TransactionOutput(locking_script=Script(b"\x6a"), satoshis=0)],
)
return tx.serialize()


def _classify(scriptsigs: list[bytes]) -> dict:
from pyrxd.hash import hash256

raw = _tx_with(scriptsigs)
txid = hash256(raw)[::-1].hex()
return _classify_raw_tx(txid, raw)


class TestSingleGlyphIsUnchanged:
"""The common case must not grow noise, or the fix costs more than it buys."""

def test_one_payload_reports_no_count_and_one_entry(self) -> None:
row = _classify([_gly_scriptsig("Solo", "SOLO")])
assert row["metadata"]["name"] == "Solo"
assert "of_n_payloads" not in row["metadata"], "no count when there is nothing to disambiguate"
assert [e["input_index"] for e in row["metadata_inputs"]] == [0]


class TestMultiGlyphRevealIsAttributedPerInput:
def test_every_payload_is_listed_with_its_own_input(self) -> None:
"""The regression: three glyphs in one reveal, three distinct names."""
row = _classify([_gly_scriptsig(n) for n in ("Alpha", "Beta", "Gamma")])
assert [(e["input_index"], e["name"]) for e in row["metadata_inputs"]] == [
(0, "Alpha"),
(1, "Beta"),
(2, "Gamma"),
]

def test_the_headline_payload_SAYS_it_is_one_of_several(self) -> None:
"""A caller reading only `metadata` must not mistake one glyph's fields for
the transaction's — which is exactly what happened before."""
row = _classify([_gly_scriptsig(n) for n in ("Alpha", "Beta", "Gamma")])
assert row["metadata"]["of_n_payloads"] == 3
assert row["metadata"]["input_index"] == 0

def test_a_payload_on_a_LATER_input_is_not_lost(self) -> None:
"""Funding inputs come first in plenty of real reveals, so the first input
carrying a payload is often not input 0."""
row = _classify([b"\x00", b"\x00", _gly_scriptsig("Late")])
assert row["metadata"]["input_index"] == 2
assert [e["name"] for e in row["metadata_inputs"]] == ["Late"]

def test_the_35_of_36_shape_seen_on_mainnet(self) -> None:
"""Scaled shape of a real reveal: one funding input, then a payload each."""
names = [f"Glyph{i}" for i in range(35)]
row = _classify([b"\x00"] + [_gly_scriptsig(n) for n in names])
assert len(row["metadata_inputs"]) == 35
assert row["metadata"]["of_n_payloads"] == 35
# every one distinct, and none attributed to the funding input
assert [e["name"] for e in row["metadata_inputs"]] == names
assert all(e["input_index"] >= 1 for e in row["metadata_inputs"])
Loading