diff --git a/CHANGELOG.md b/CHANGELOG.md index 402b917a..7baa894d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/pyrxd/cli/glyph_inspect.py b/src/pyrxd/cli/glyph_inspect.py index 9cf35d70..70f00d21 100644 --- a/src/pyrxd/cli/glyph_inspect.py +++ b/src/pyrxd/cli/glyph_inspect.py @@ -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'])}") @@ -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. diff --git a/src/pyrxd/glyph/_inspect_core.py b/src/pyrxd/glyph/_inspect_core.py index 863b64e0..9dba31b1 100644 --- a/src/pyrxd/glyph/_inspect_core.py +++ b/src/pyrxd/glyph/_inspect_core.py @@ -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), @@ -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, } diff --git a/tests/test_multi_glyph_reveal_attribution.py b/tests/test_multi_glyph_reveal_attribution.py new file mode 100644 index 00000000..bd0f67b2 --- /dev/null +++ b/tests/test_multi_glyph_reveal_attribution.py @@ -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: "gly" , 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"])