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
13 changes: 13 additions & 0 deletions docs/inspect_static/inspect/glue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
100 changes: 99 additions & 1 deletion docs/inspect_static/inspect/inspect.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand All @@ -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
Expand Down Expand Up @@ -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 " +
Expand Down Expand Up @@ -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`));
Expand Down
189 changes: 188 additions & 1 deletion tests/web/test_inspect_js_render_drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@
"p2pkh",
"p2sh",
"op_return",
"op_return-msg",
"op_return-hashmark-v1",
"op_return-hashmark-v2",
"nft",
"ft",
"mut",
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)]
Expand All @@ -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": <script payload>, "row": <tx-row payload>}}``.
Expand Down Expand Up @@ -551,3 +610,131 @@ def test_error_rows_still_render_their_reason(self):

if __name__ == "__main__": # pragma: no cover
sys.exit(pytest.main([__file__, "-q"]))


class TestTheCorpusCoversEveryShapeTheClassifierCanEmit:
"""The guard above is only as wide as `_SHAPE_NAMES`, which is hand-kept.

That is how the OP_RETURN payload shapes slipped past it: `hashmark`,
`attestation` and `message` were added to the Python classifier and to neither
card, and "every key must be rendered" never fired because no shape in the
corpus produced those keys. The structural guard was structural about FIELDS and
hand-kept about SHAPES.

So the shape list is now checked against the type strings the classifier can
actually emit, recovered from its source. A new `type` with no corpus entry
fails here rather than silently narrowing every test in this file.
"""

#: Types no pasted script can produce, with the reason.
_UNREACHABLE = {
"error": "produced only when classification RAISES; there is no script that yields it",
}

@staticmethod
def _emitted_by_the_source() -> set[str]:
"""Every `type` value written as a literal in the classifier, plus the
prefix of every one built by an f-string."""
import ast
import pathlib

source = pathlib.Path(_REPO_ROOT / "src/pyrxd/glyph/_inspect_core.py").read_text(encoding="utf-8")
found: set[str] = set()
for node in ast.walk(ast.parse(source)):
targets = []
if isinstance(node, ast.Dict):
targets = [
v
for k, v in zip(node.keys, node.values, strict=False)
if isinstance(k, ast.Constant) and k.value == "type"
]
elif isinstance(node, ast.Assign):
targets = [
node.value
for t in node.targets
if isinstance(t, ast.Subscript) and isinstance(t.slice, ast.Constant) and t.slice.value == "type"
]
for value in targets:
if isinstance(value, ast.Constant) and isinstance(value.value, str):
found.add(value.value)
elif isinstance(value, ast.JoinedStr) and value.values:
# f"op_return-hashmark-v{version}" -> the literal prefix, which is
# enough to demand SOME corpus shape of that family.
head = value.values[0]
if isinstance(head, ast.Constant) and isinstance(head.value, str):
found.add(head.value)
if len(found) < 10:
raise AssertionError(
f"only {len(found)} type values parsed from _inspect_core.py — the extraction is broken"
)
return found

def test_every_emitted_type_has_a_corpus_shape(self, payloads) -> None:
produced = {payloads[name]["script"].get("type", "") for name in _SHAPE_NAMES}
missing = []
for emitted in sorted(self._emitted_by_the_source()):
if emitted in self._UNREACHABLE:
continue
# Exact for a literal type; prefix for an f-string family.
if not any(t == emitted or t.startswith(emitted) for t in produced):
missing.append(emitted)
assert not missing, (
f"the classifier can emit {missing} and no corpus shape produces it, so every "
f"test in this file is blind to those shapes. Add one to `_SHAPE_NAMES` and "
f"`_corpus()`, or record it in `_UNREACHABLE` with a reason.\n"
f"currently produced: {sorted(produced)}"
)

def test_the_extraction_is_not_vacuous(self) -> None:
"""A parser returning an empty set would make the check above pass forever."""
emitted = self._emitted_by_the_source()
assert {"p2pkh", "op_return", "op_return-hashmark-v"} <= emitted

def test_no_corpus_shape_is_unreachable_from_the_classifier(self, payloads) -> None:
"""The other direction: a shape whose type no longer exists is a test that
silently stopped covering anything."""
emitted = self._emitted_by_the_source()
for name in _SHAPE_NAMES:
produced = payloads[name]["script"].get("type", "")
assert any(produced == e or produced.startswith(e) for e in emitted), (
f"corpus shape {name!r} classifies as {produced!r}, which the classifier "
f"source no longer emits — this shape is guarding nothing"
)


class TestTheNestedRequirementIsNotVACUOUS:
"""A nested block must demand SOMETHING on screen.

`_required_evidence` recurses into `hashmark` / `message` / `attestation` and
skips leaves listed in `_OMITTED_NESTED_KEYS`. If a block's every leaf ended up
listed there, it would return no requirements at all and the block would count
as "rendered" while the card showed nothing — the same shape as the corpus gap
that let these fields go unrendered in the first place, one level down.

The omission table is the right mechanism; it just needs a floor under it.
"""

_NESTED = {"hashmark": ("op_return-hashmark-v2", 3), "message": ("op_return-msg", 1)}

@pytest.mark.parametrize("key", sorted(_NESTED))
def test_the_block_demands_evidence(self, key, payloads) -> None:
shape, minimum = self._NESTED[key]
block = payloads[shape]["row"][key]
evidence = _required_evidence(key, block)
assert len(evidence) >= minimum, (
f"{key!r} on shape {shape!r} requires only {len(evidence)} evidence strings "
f"({evidence}). Every leaf that matters has been omitted, so this block now "
f"passes whether or not the renderer shows it."
)

def test_the_digest_specifically_must_be_shown(self, payloads) -> None:
"""The one field that identifies WHICH file was marked. A HashMark card
without it is decoration."""
block = payloads["op_return-hashmark-v2"]["row"]["hashmark"]
assert block["digest"] in _required_evidence("hashmark", block)

def test_the_attestation_detail_must_be_shown(self, payloads) -> None:
"""The verdict's reason. Dropping it is how "does not verify" becomes a bare
badge again."""
block = payloads["op_return-hashmark-v2"]["row"]["hashmark"]
assert block["attestation"]["detail"] in _required_evidence("hashmark", block)
Loading