Verify an attestation yourself

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.

1. What is attested

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.

endpoints, 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.

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.

2. Getting a uid

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.

3. The checks

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.

4. A worked example

Real values from a claim on this feed, kept in the repository as the fixture the recipe

below is tested against on every build.

5. Run it yourself

The 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))

6. What this proves, and what it does not

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.

outcome 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.

7. Where the mechanism is described

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].