Every FDA decision date this feed serves is signed by the oracle and anchored on a public
blockchain once a day. This page is the recipe for checking one with nothing but public
data: no account, no payment, no software of ours. If you cannot reproduce every step below
from your own machine, the stamp is worth nothing, and we would rather you found that out
than took our word for it.
Status: testnet trial. The ledger currently runs on base-sepolia. No attestation uid is issued to readers during the trial, and trial attestations are not carried over: the mainnet ledger starts empty on the day of the switch, which will be announced in the changelog. The recipe on this page is the same on both chains; only the chain id and the example change.
A claim is one served fact: feed biotech-catalyst, subject catalyst:<id>, event type
pdufa_date, the expected date as a Unix day, the SEC filing the date was read from
(sourceUrl) and the SHA-256 of that filing's stored text (sourceHash), and a revision
number. When a served date moves, the oracle does not edit the claim; it signs a new one
with revision + 1 whose refUID points at the previous one, so every date ever asserted
stays visible. A resolution closes a claim chain with the FDA's actual decision. Both are
EIP-712 typed-data attestations under the Ethereum Attestation Service (EAS) schema shown in
every verify response, signed off-chain by the attester below.
Each day, the uids of everything signed that day are hashed into a Merkle tree and the root
is timestamped in the EAS contract on-chain. So a single on-chain transaction commits to
every attestation of that day, and each attestation carries the proof that it was in it.
0xE4759a7d51584b389560b774b6507D6AdDA70beF. This address is stated by our ownendpoints, so corroborate it independently: the daily anchor transaction is sent from the
same key, and a transaction's sender is a fact the chain records, not one we assert.
base-sepolia (chain id 84532)verifyingContract and the timestamp registry): 0x42000000000000000000000000000000000000210xa942f31d9c5ea4ce9eae9ba0494899c788eaa615dda761a552f7c244903e3716):string feedId,string subjectId,string eventType,uint64 expectedDate,string sourceUrl,bytes32 sourceHash,uint16 revision. The signed message.data is the ABI encoding of the served payload
under exactly these types, so you can decode it and compare rather than trust payload.
What sourceHash is. The claim policy is: sourceUrl names the ONE EDGAR document
the date was read from (the primary body or the press-release exhibit — whichever
contains the quoted sentence) and sourceHash is the SHA-256 of the raw bytes EDGAR
serves at that URL, exactly as a download yields them, before any processing of ours. So
a stranger recomputes it with curl -o doc <sourceUrl>; sha256sum doc. EDGAR archives are
immutable, so the bytes do not change under you.
One honest caveat during the trial: claims signed before this policy landed (the
worked example below is one) point at the filing's index page and hash the filing text
as this feed stored it after HTML-to-text normalisation — a commitment that binds the
claim to one stored document, but not a hash you can recompute. The trial ledger is
reset before the mainnet start, so every mainnet claim follows the policy above, and
this page's example and its script gain the download-and-compare check at that point.
During the trial no surface issues uids. The worked example below uses one real trial uid so the recipe can be exercised end to end today.
Given a uid, everything else is one free request:
GET https://api.biotechcatalystsentinel.com/attest/verify?uid=<uid> returns the signed
attestation, its decoded payload, and the anchor (Merkle root, inclusion proof, the anchoring
transaction hash and the day). GET https://api.biotechcatalystsentinel.com/attest/accuracy/biotech-catalyst
returns the attester address and the chain, plus counts over the whole ledger.
Check 0 — the uid is the message. The signature (check 1) covers the message; the
Merkle proof (check 2) covers the uid. What ties them together is that the uid is derived
from the message, by the EAS off-chain uid rule: keccak256 of the tightly packed
`uint16 version ‖ bytes(utf-8 of the schema uid's hex string) ‖ address recipient ‖
address 0x0 ‖ uint64 time ‖ uint64 expirationTime ‖ bool revocable ‖ bytes32 refUID ‖
bytes data ‖ bytes32 salt ‖ uint32 0`. (Two quirks are the SDK's, not ours: the schema uid
is packed as the bytes of its hex *string*, and the attester slot is the zero address.)
Recompute it and compare with the served uid; if they differ, checks 1 and 2 are about two
different things.
Check 1 — the signature. Rebuild the EIP-712 typed data from the response exactly as
served (domain, types, primaryType, message), hash it, and recover the signer from
the {v, r, s} signature. The recovered address must equal the attester published by the
accuracy endpoint. Do not let the response choose the domain: domain.chainId must be
84532, domain.verifyingContract must be 0x4200000000000000000000000000000000000021, and message.schema
must be the claim schema uid above — a signature under some other domain or schema is not
a claim on this feed, however valid it is. This proves the oracle, and only the oracle,
said this.
Check 2 — the Merkle proof. The leaf is keccak256(0x00 ++ uid) (a one-byte leaf tag
before the 32 uid bytes). Walk inclusion_proof in order: at each step hash
keccak256(0x01 ++ min(node, sibling) ++ max(node, sibling)) (a one-byte node tag, then the
two 32-byte values in ascending byte order, so the hash is commutative). The final value
must equal anchor.merkle_root. The tags stop an inner node from ever being presented as a
member; the sorted order means the proof needs no left/right flags. Leaves were sorted and
de-duplicated before the tree was built, which changes nothing about how you fold a proof.
Check 3 — the chain. Ask any base-sepolia JSON-RPC endpoint to call
getTimestamp(bytes32) on the EAS contract at 0x4200000000000000000000000000000000000021 (the address on this page,
never one taken from the response) with the root: eth_call with data 0x + the 4-byte
selector of getTimestamp(bytes32) + the root. A non-zero answer is the Unix time of the
block that recorded the root. anchor.anchored_at is the time we *submitted* the anchor, so
expect the chain's answer a few seconds later, never earlier. Or fetch the receipt of
anchor.tx_hash and find the Timestamped(bytes32,uint64) event emitted by the EAS
contract: topics[0] is the event signature, topics[1] is the root and topics[2] the
timestamp. The receipt's from is the attester, which is the independent corroboration of
the attester address mentioned above. This proves the day's set of attestations existed by
that time and has not changed since.
A response with pending_anchor: true has passed check 1 only: it is signed but the day's
root has not been written yet (anchors land daily). Come back after the next anchor.
Real values from a claim on this feed, kept in the repository as the fixture the recipe
below is tested against on every build.
0xf8e4ae8edf311b6285f8c74dbf2c873da1e5f9addc93669c5aa13c9492b1b2d3 — subject catalyst:1, expected date 2026-11-25 (Unix day 1795564800), revision 0https://www.sec.gov/Archives/edgar/data/1121404/000162828026050803/0001628280-26-050803-index.htm with sourceHash 0x628af711377f89cfc255aee60686c21b1245f4694d74dae2fbf11f172d51553a0xE4759a7d51584b389560b774b6507D6AdDA70beF0x4c4934c8c5c794cf16aec791ca144c1800e857f1999d925fe4d8ddf2f04bc7b1 (44 members that day)base-sepolia at 2026-09-04 08:51:12 UTC by transaction 0x5c4fcd72e06b778187dd43e839a3a07d872502aaa827dacee8cbfcb8d6f5b93aThe script below is the whole recipe. It needs three ordinary packages
(pip install eth-account eth-utils httpx) and none of ours. Run it as
python verify_attestation.py <uid> [rpc-url]; without an RPC url it performs checks 0
to 2, with one it performs all of them. The chain id, contract and schema uid are
constants in the script, generated from the same source as this page, so the script
enforces what the prose says rather than trusting the response.
"""Verify one Biotech Catalyst Sentinel attestation from public data only."""
import json
import sys
import httpx
from eth_account import Account
from eth_account.messages import encode_typed_data
from eth_utils import keccak
VERIFY_URL = "https://api.biotechcatalystsentinel.com/attest/verify"
ACCURACY_URL = "https://api.biotechcatalystsentinel.com/attest/accuracy/biotech-catalyst"
EXPECTED_CHAIN_ID = 84532
EXPECTED_EAS = "0x4200000000000000000000000000000000000021"
EXPECTED_SCHEMA_UID = "0xa942f31d9c5ea4ce9eae9ba0494899c788eaa615dda761a552f7c244903e3716"
EIP712_DOMAIN = [
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"},
{"name": "verifyingContract", "type": "address"},
]
def _b(hex_str):
return bytes.fromhex(hex_str[2:])
def recompute_uid(message):
"""Check 0: the EAS off-chain uid rule — the uid is a hash of the message."""
packed = (
int(message["version"]).to_bytes(2, "big")
+ message["schema"].encode("utf-8") # the hex STRING's bytes (SDK quirk)
+ _b(message["recipient"])
+ bytes(20) # attester slot is the zero address in the off-chain uid
+ int(message["time"]).to_bytes(8, "big")
+ int(message["expirationTime"]).to_bytes(8, "big")
+ bytes([1 if message["revocable"] else 0])
+ _b(message["refUID"])
+ _b(message["data"])
+ _b(message["salt"])
+ (0).to_bytes(4, "big") # bump
)
return "0x" + keccak(packed).hex()
def domain_is_expected(att):
"""Check 1, first half: this signature is over THIS feed's domain and schema."""
domain = att["domain"]
return (
int(domain["chainId"]) == EXPECTED_CHAIN_ID
and domain["verifyingContract"].lower() == EXPECTED_EAS.lower()
and att["message"]["schema"].lower() == EXPECTED_SCHEMA_UID.lower()
)
def recover_signer(att):
"""Check 1, second half: the address that signed the served typed data."""
typed = {
"types": {"EIP712Domain": EIP712_DOMAIN, **att["types"]},
"primaryType": att["primaryType"],
"domain": att["domain"],
"message": att["message"],
}
sig = att["signature"]
return Account.recover_message(
encode_typed_data(full_message=typed), vrs=(sig["v"], sig["r"], sig["s"])
)
def fold_root(uid_hex, proof_hex):
"""Check 2: leaf = keccak(0x00 ++ uid); node = keccak(0x01 ++ min ++ max)."""
node = keccak(b"\x00" + bytes.fromhex(uid_hex[2:]))
for sibling_hex in proof_hex:
sibling = bytes.fromhex(sibling_hex[2:])
lo, hi = (node, sibling) if node <= sibling else (sibling, node)
node = keccak(b"\x01" + lo + hi)
return "0x" + node.hex()
def onchain_timestamp(rpc_url, root_hex):
"""Check 3: EAS.getTimestamp(root) via eth_call on EXPECTED_EAS; 0 = never timestamped."""
selector = keccak(text="getTimestamp(bytes32)")[:4].hex()
call = {
"jsonrpc": "2.0",
"id": 1,
"method": "eth_call",
"params": [{"to": EXPECTED_EAS, "data": "0x" + selector + root_hex[2:]}, "latest"],
}
return int(httpx.post(rpc_url, json=call, timeout=30).json()["result"], 16)
def verify(response, attester, rpc_url=None):
"""Return {check: result}. None means the check could not run yet."""
att = response["attestation"]
checks = {
"uid_matches_message": recompute_uid(att["message"]).lower() == response["uid"].lower(),
"domain_is_expected": domain_is_expected(att),
"signer_is_attester": recover_signer(att).lower() == attester.lower(),
}
anchor = response.get("anchor")
if not anchor:
checks["proof_folds_to_root"] = None # pending_anchor: signed, not yet timestamped
return checks
checks["proof_folds_to_root"] = fold_root(response["uid"], anchor["inclusion_proof"]) == anchor["merkle_root"]
if rpc_url:
ts = onchain_timestamp(rpc_url, anchor["merkle_root"])
checks["root_timestamped_on_chain"] = ts > 0
checks["onchain_timestamp_unix"] = ts
return checks
if __name__ == "__main__":
uid = sys.argv[1]
rpc = sys.argv[2] if len(sys.argv) > 2 else None
response = httpx.get(VERIFY_URL, params={"uid": uid}, timeout=30).json()
attester = httpx.get(ACCURACY_URL, timeout=30).json()["attester"]
print(json.dumps(verify(response, attester, rpc), indent=2))
date is right; that is what the free accuracy scoreboard is for, where every past date is
scored against the FDA's own record. (Today no public path leads from a claim's subject
to its scoreboard row; that link arrives with the uids on served rows.)
is a deliberate simplicity for the trial; it means a compromise of one key would affect
both, and it is why the anchor's sender doubles as the corroboration of the attester.
altered since. It does not prove it was signed at the time in the message; only that
it was signed before the anchor.
superseded_by and resolution_uid on a verify response are how a correction or anoutcome stays visible beside the original claim. A superseded claim still verifies; it is
simply no longer the current date.
see in SEC filings. Absence of a claim is not a claim of absence.
The wiring of this feed onto the attestation package, including the claim and resolution
policies and the failure policy, is documented in the repository and summarised in the
changelog. Questions: [email protected].