From 4305eeb15e0514907c31379d7434ed3f0568f4de Mon Sep 17 00:00:00 2001 From: Mudwood Labs Date: Thu, 3 Sep 2026 01:42:17 -0700 Subject: [PATCH 1/2] render the OP_RETURN payload verdicts on the public inspect page The browser page rendered NONE of the OP_RETURN payload fields. A forged HashMark v2 showed as an authoritative-looking `OP_RETURN-HASHMARK-V2` badge with no signer and NO VERDICT: the affirmative half of the record survived and the part that contradicts it did not. Same defect I just fixed in the CLI, on the surface with the widest audience and no install step. The data was never the problem. glue.py re-uses the same classify_raw_tx the CLI uses, so hashmark, attestation, message and relationships were all in the payload, crossing the bridge, and discarded by the renderer. `renderOutputRow` already carried the rule in a comment - "a field that only the standalone-script card shows is a field most readers never see" - so this is the third instance of the shape that comment was written about. There is now ONE `appendOpReturnPayload` used by both cards rather than a second copy. Two smaller things the rename broke, both found by reading rather than reported: the title map and `_structuralQualifierNote` key on the literal "op_return", so renaming the type to `op_return-hashmark-v2` silently dropped the card title AND the "unspendable data carrier" note from exactly the outputs that had just gained content. WHY THE DRIFT GUARD DID NOT CATCH IT. tests/web asserts that every key the classifier emits appears in the rendered text - which is structural about FIELDS and hand-kept about SHAPES. The check only ever saw the payloads that `_SHAPE_NAMES` produces, and no shape in that tuple produced a hashmark or a message, so "every key must be rendered" was vacuously true for the keys that had just been added. A hand-kept list of what a guard covers, for the third time in this session. Fixed at that level too: the corpus is now checked against the `type` values recovered from the classifier's own source, both directions - a type with no corpus shape means every test in that file is blind to it, and a corpus shape whose type no longer exists is guarding nothing. The v2 shape carries a random signature deliberately, so it decodes and does NOT verify: "does not verify" is the line that was missing from this page entirely, and a corpus of only valid records would not have demanded it. The guard also now recurses into nested payload blocks rather than asserting the repr of a dict, which no renderer could ever emit. Leaves rendered as prose are listed with reasons, the same rule the top-level omission tables already use. Also fixed: glue.py truncated `hex` and `data_hex` to 200 chars while inspect.js told the reader "the JSON drawer carries the full bytes", so the drawer and the Copy JSON button held a prefix, beside a header reporting the true byte count. Newly material rather than untidy - `data_hex` is now the only place a HashMark or msg record's raw bytes appear, and the UI points at it. The row stays scannable because inspect.js truncates for DISPLAY at 64 chars on its own, which is the right layer: only the display needs to be short. Verified by planting three defects, each caught: removing the shared renderer from either card (2 failures each), and forgetting to add the new shapes to the corpus, which the coverage check names explicitly. Co-Authored-By: Claude Opus 5 (1M context) --- docs/inspect_static/inspect/glue.py | 13 ++ docs/inspect_static/inspect/inspect.js | 100 +++++++++++++- tests/web/test_inspect_js_render_drift.py | 151 +++++++++++++++++++++- 3 files changed, 262 insertions(+), 2 deletions(-) diff --git a/docs/inspect_static/inspect/glue.py b/docs/inspect_static/inspect/glue.py index add9a8f3..15eee580 100644 --- a/docs/inspect_static/inspect/glue.py +++ b/docs/inspect_static/inspect/glue.py @@ -427,6 +427,19 @@ def _truncate(s: str, cap: int = _HUMAN_STRING_CAP) -> str: "payload_hash", "wire_hex", "input", + # The script bytes themselves. They were chopped to 200 hex chars while + # inspect.js told the reader the opposite — "the JSON drawer carries the + # full bytes" — so the drawer, and the Copy JSON button, silently held a + # prefix. The card printed the true byte count beside it, showing + # "length: 258 bytes" above 100 bytes of hex. + # + # Newly material rather than merely untidy: `data_hex` is now the only + # place a HashMark or `msg` record's raw bytes appear, and it is what the + # UI points at ("not valid UTF-8 — see data_hex"). The row stays scannable + # because inspect.js truncates for DISPLAY at 64 chars on its own; that is + # the right layer for it, since only the display needs to be short. + "hex", + "data_hex", # dMint mint-claim scriptSig pushes — exact bytes are load-bearing # for verifying a covenant push against an off-chain re-derivation. "nonce_hex", diff --git a/docs/inspect_static/inspect/inspect.js b/docs/inspect_static/inspect/inspect.js index 1c168956..47f3d7ac 100644 --- a/docs/inspect_static/inspect/inspect.js +++ b/docs/inspect_static/inspect/inspect.js @@ -722,6 +722,92 @@ function renderFetchedTxCard(payload) { return wrapper; } +// The OP_RETURN payload decoders (HashMark, the Photonic `msg` convention) and +// the relationship verifier, for EVERY card that can show one. +// +// One function because there are two cards and there was one renderer — and the +// comment inside `renderOutputRow` already states the rule: "a field that only +// the standalone-script card shows is a field most readers never see." The new +// fields were added to the Python classifier and to neither card, so this page +// rendered a forged HashMark as an authoritative-looking `OP_RETURN-HASHMARK-V2` +// badge with no signer and NO VERDICT. The affirmative half of the record +// survived and the part that contradicts it did not. +// +// Every value goes through `kv`, which assigns to textContent, so an attacker's +// label cannot become markup. That is why the CLI needed an escaping fix here and +// this does not. +function appendOpReturnPayload(dl, row) { + const msg = row.message; + if (msg) { + if (msg.outcome === "ok") { + if (msg.is_utf8) { + dl.appendChild(kv(`message (${msg.byte_length} bytes)`, msg.text)); + } else { + // Say WHY there is no text, or a reader assumes the field is empty + // rather than that the bytes simply are not text. + dl.appendChild(kv("message", `${msg.byte_length} bytes, not valid UTF-8 (see data_hex)`)); + } + } else { + dl.appendChild(kv("message", msg.detail ? `${msg.outcome} — ${msg.detail}` : msg.outcome)); + } + } + + const hm = row.hashmark; + if (hm) { + if (hm.outcome !== "ok") { + dl.appendChild(kv("hashmark", hm.detail ? `${hm.outcome} — ${hm.detail}` : hm.outcome, "kv-warning")); + } else { + dl.appendChild(kv("hashmark", `v${hm.version} (${hm.algorithm})`)); + dl.appendChild(kv("digest", hm.digest)); + if (hm.label) { + dl.appendChild(kv("label", hm.label)); + } else if (hm.label_withheld) { + // v1 keeps its timestamp evidence; the label is withheld WITH a reason, + // because showing nothing looks like a record that carried no label. + dl.appendChild(kv("label", `[withheld — ${hm.label_withheld}]`, "kv-warning")); + } + if (hm.signer_hash160) { + dl.appendChild(kv("signer", hm.signer_hash160)); + const att = hm.attestation || {}; + if (att.outcome === "valid") { + dl.appendChild(kvWithWarning( + "signature", + "VERIFIED — recovers to the committed signer", + `assuming ${att.assumed_network}; the chain is part of the signed statement`, + )); + } else if (att.outcome === "invalid_signature") { + // The bytes decoded; the CLAIM does not hold. Calling it "malformed" + // would send a reader after the wrong problem. + dl.appendChild(kv( + "signature", + `DOES NOT VERIFY — ${att.detail || "no detail"} (the record is well-formed; its claim is not supported)`, + "kv-warning", + )); + } + } + dl.appendChild(kv( + "what this proves", + "someone knew this digest no later than the confirming block — not authorship, ownership, originality or contents", + )); + } + } + + // Declared container/creator membership, WITH its verdict. `in` and `by` are + // operator-supplied CBOR — anyone can name any collection — so the claim is + // never shown without whether the transaction was authorised to carry it. + const rels = (row.metadata && row.metadata.relationships) || row.relationships; + if (Array.isArray(rels)) { + for (const rel of rels) { + const backed = rel.outcome === "backed"; + dl.appendChild(kv( + rel.kind === "author" ? "creator claim" : "collection claim", + `${rel.ref} — ${backed ? "VERIFIED (spent in this tx)" : "UNVERIFIED CLAIM (nothing in this tx authorises it)"}`, + backed ? undefined : "kv-warning", + )); + } + } +} + function renderOutputRow(row) { const type = String(row.type || "unknown").toLowerCase(); const wrapper = el("section", { class: "output-row" }); @@ -745,6 +831,9 @@ function renderOutputRow(row) { // `_render_txid_human` omits both for the same reason). const relativeLockDisabled = row.relative_lock_disabled === true; const dl = el("dl", { class: "kv-list" }); + // FIRST, because on an OP_RETURN row it is the whole content of the row, and + // because "signature DOES NOT VERIFY" must not sit below a scroll of kv pairs. + appendOpReturnPayload(dl, row); if (row.owner_pkh) dl.appendChild(kv("owner pkh", row.owner_pkh)); if (row.ref_outpoint) dl.appendChild(kv("ref", row.ref_outpoint)); // Dead pre-0.15.0 CONTAINER output. The child ref is the reason it cannot @@ -1132,6 +1221,10 @@ function _detectTxShape(payload) { // the disabled shape (``_render_timelock_body``); so does this. Nothing here // re-derives the flag: ``relative_lock_disabled`` is decided in Python. function _structuralQualifierNote(type, payload) { + // A recognised payload renames the type to `op_return-hashmark-v2` / + // `op_return-msg`, so a lookup on the bare literal silently dropped the + // OP_RETURN note from exactly the outputs that had just gained content. + if (typeof type === "string" && type.startsWith("op_return-")) type = "op_return"; if (type === "p2pkh-csv" && payload && payload.relative_lock_disabled === true) { return "Structural pattern match. Bit 31 of the sequence " + "(SEQUENCE_LOCKTIME_DISABLE_FLAG) is set, so consensus enforces no " + @@ -1231,9 +1324,14 @@ function renderScriptCard(payload) { op_return: "OP_RETURN data output", unknown: "Unrecognised script", }; - const wrapper = card(titleMap[type] || "Locking script", scriptBadgeKind(type)); + // `type` is now `op_return-hashmark-v2` / `op_return-msg` for a recognised + // payload, so a map keyed on the bare literal loses the title AND the structural + // note for exactly the outputs that gained content. + const baseType = type.startsWith("op_return") ? "op_return" : type; + const wrapper = card(titleMap[type] || titleMap[baseType] || "Locking script", scriptBadgeKind(type)); const dl = el("dl", { class: "kv-list" }); + appendOpReturnPayload(dl, payload); dl.appendChild(kv("type", type)); if (payload.length !== undefined) { dl.appendChild(kv("length", `${payload.length} bytes`)); diff --git a/tests/web/test_inspect_js_render_drift.py b/tests/web/test_inspect_js_render_drift.py index 55f8771a..c371d264 100644 --- a/tests/web/test_inspect_js_render_drift.py +++ b/tests/web/test_inspect_js_render_drift.py @@ -85,6 +85,9 @@ "p2pkh", "p2sh", "op_return", + "op_return-msg", + "op_return-hashmark-v1", + "op_return-hashmark-v2", "nft", "ft", "mut", @@ -163,6 +166,27 @@ def _corpus() -> dict[str, bytes]: "p2pkh": b"\x76\xa9\x14" + bytes(pkh) + b"\x88\xac", "p2sh": b"\xa9\x14" + os.urandom(20) + b"\x87", "op_return": b"\x6a" + b"\x4c\x28" + os.urandom(40), + # The OP_RETURN payload shapes. Built here rather than imported from a + # fixture because the point is what the CLASSIFIER emits for real bytes. + "op_return-msg": b"\x6a\x03msg\x0bhello there", + "op_return-hashmark-v1": ( + b"\x6a\x08HASHMARK\x02" + bytes([1, 1]) + b"\x20" + os.urandom(32) + b"\x0breport.pdf" + ), + # v2 carries a signer and a signature, so it is the shape whose ATTESTATION + # VERDICT must reach the reader. The signature here is random, so the record + # decodes and does NOT verify — deliberately, because "does not verify" is + # the line that was missing from this page entirely. + "op_return-hashmark-v2": ( + b"\x6a\x08HASHMARK\x02" + + bytes([2, 1]) + + b"\x20" + + os.urandom(32) + + b"\x14" + + os.urandom(20) + + b"\x41" + + bytes([31]) + + os.urandom(64) + ), "nft": build_nft_locking_script(pkh, ref), "ft": build_ft_locking_script(pkh, ref), "mut": build_mutable_nft_script(ref, payload_hash), @@ -236,11 +260,38 @@ def _js_string(value) -> str: return str(value) +# Keys INSIDE a nested payload block (hashmark, message, attestation) that the +# renderer may drop. Same rule as the top-level tables: listed with a reason, or +# it must appear. Without this the guard would demand the literal value of every +# leaf, including ones that are deliberately rendered as prose. +_OMITTED_NESTED_KEYS = { + "outcome": "rendered as prose — 'v2 (sha256)' when ok, 'DOES NOT VERIFY' or the " + "outcome text otherwise. The literal 'ok' would tell a reader nothing", + "signature_unverified": "the raw 65-byte signature. The VERDICT is what a reader " + "needs and the bytes are in the JSON drawer; the CLI omits it for the same reason", + "assumed_network": "shown beside a VERIFIED signature, where the assumption is " + "load-bearing. On a failure the reason is the detail, not the chain", + "is_utf8": "rendered as prose — either the decoded text, or 'not valid UTF-8'", + "recovered_hash160": "identical to the committed signer whenever it is set, and " + "the signer is already rendered; printing both invites reading them as two facts", +} + + def _required_evidence(key: str, value) -> list[str]: """Substrings the rendered text must contain for *key* to count as shown.""" - if key in _PROSE_EVIDENCE and value in _PROSE_EVIDENCE[key]: + if key in _PROSE_EVIDENCE and _hashable(value) and value in _PROSE_EVIDENCE[key]: phrase = _PROSE_EVIDENCE[key][value] return [] if phrase is None else [phrase] + if isinstance(value, dict): + # A nested block (hashmark, message, attestation). Recurse to its LEAVES: + # asserting the dict's repr would be satisfied by nothing a renderer emits, + # and skipping it entirely is how the whole block went unrendered. + return [ + evidence + for sub_key, sub_value in value.items() + if sub_key not in _OMITTED_NESTED_KEYS + for evidence in _required_evidence(sub_key, sub_value) + ] if isinstance(value, list): # input_refs / referenced_refs: every entry, both fields. return [str(field) for entry in value for field in entry.values() if str(field)] @@ -252,6 +303,14 @@ def _required_evidence(key: str, value) -> list[str]: return [text[:64]] +def _hashable(value) -> bool: + try: + hash(value) + except TypeError: + return False + return True + + @functools.lru_cache(maxsize=1) def _payloads() -> dict[str, dict[str, dict]]: """``{shape: {"script":