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..2690ccc4 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":