Status: Accepted
Date: 2026-06-27
Branch / PR: fix/beacon-signal-validation
References: ADR 016, ADR 037, ADR 055
Beacon signal discovery is the entry point of the resolver’s read path: it scans the Bitcoin transactions at each beacon address and extracts the 32-byte update or announcement hash that each signal commits to. A beacon signal is a single OP_RETURN data push, on the wire 0x6a 0x20 <32 bytes>, whose asm form is exactly OP_RETURN OP_PUSHBYTES_32 <64-hex>. The encode side is already pinned (opReturnScript produces precisely that 34-byte NULL_DATA script).
The decode side was lax. Both discovery paths (the Esplora REST indexer and the Bitcoin Core fullnode traversal) checked only that the output’s scriptpubkey_asm contained the substring OP_RETURN, then took the last whitespace-delimited asm token as the signal hash, with an empty-string check as the only guard. Two failure modes followed:
Phantom signals from malformed outputs. A bare OP_RETURN with no push yields the literal token OP_RETURN as the “hash”. A push of the wrong size, or a non-hex payload, yields a short or non-hex “hash”. Either way a value that is not a 32-byte commitment flows downstream as if it were a real signal, producing a sidecar-map miss and an opaque, far-from-source failure (or, with adversarial sidecar data, a lookup against an attacker-chosen key).
Substring false positives. Because the check was includes('OP_RETURN') rather than “the output is an OP_RETURN data push,” any script whose asm merely mentioned the keyword could be misread as a signal.
Separately, the CAS beacon’s resolution path links an on-chain signal to a signed update through two hashes with an encoding transition at each hop (hex on-chain and for map keys, base64urlnopad for announcement values). ADR 055 made provide() enforce those hashes, and pre-loaded sidecar maps enforce them structurally by keying on canonicalHash. But the chain itself, and specifically its hex/base64url transitions, was undocumented and had no regression guard, so a future change to a default encoding could silently break resolution: every lookup would simply miss.
A single shared decoder, extractOpReturnSignal(asm), returns the 32-byte hash if and only if the asm is exactly three tokens, OP_RETURN, then OP_PUSHBYTES_32, then a 64-character hex payload, and returns null for everything else (empty input, a bare OP_RETURN, a wrong-size push opcode, a payload that is not exactly 32 bytes of hex, a multi-push output, or a script where OP_RETURN is not the leading opcode). The payload is lowercased so it matches the hex-keyed sidecar maps. Both the REST and fullnode discovery paths now route through this one function and drop any output it rejects.
The two-hop chain and its encoding transitions are documented inline on CASBeacon (the signal hop: on-chain signalBytes in hex equals canonicalHash(announcement, hex), the casMap key; the update hop: each announcement value, base64urlnopad, decodes to hex to equal canonicalHash(signedUpdate, hex), the updateMap key). A regression test pins both identities directly and drives a broadcast-shaped announcement through processSignals end-to-end, so a drift in any default encoding fails loudly at that test rather than silently breaking resolution.
OP_RETURN push is ignored during discovery, so it can no longer surface as a phantom signal. Only genuine 32-byte commitments enter the resolver.canonicalHash’s default encoding or to the announcement-value encoding fails a fast, local test rather than silently producing empty resolutions.OP_RETURN OP_PUSHBYTES_32 <32-byte> push” is the real predicate the read path needs, and is what the encode side already produces.processSignals. Redundant: provide() already validates supplied data against the need’s hash, and pre-loaded maps are keyed by canonicalHash, so a mismatched entry simply misses the lookup. The remaining gap was documentation and a regression guard, not another runtime check.