Public anchoring
Every proof Sable issues — a receipt, a run proof, an audit pack, an agent
passport — is signed with a secp256k1 key whose address is published at
GET /v1/receipts/pubkey. Pin that address once and every signature verifies
offline, forever, without asking Sable anything.
That still leaves one gap. A signature proves who said something. It does not prove when, and it does not survive Sable disappearing. So Sable also publishes commitments — hashes, and only hashes — to public chains, batched into roots. An anchor is a timestamped, third-party-witnessed statement that a given digest existed by a given block.
This page is the recipe. Everything below is recomputable by hand; the independent verifier does it in your browser, and both SDKs do it offline.
What an anchor does and does not prove
An anchor proves a commitment existed no later than the transaction or blob that carries its batch root. It does not prove the facts behind the commitment are true.
Concretely, for an agent passport:
| Claim | Proven by |
|---|---|
| This credential was issued by this Sable deployment | the EIP-191 signature, checked against a pinned signer |
| This credential is byte-for-byte the one that was committed | the commitment: canonical JSON → sha256 |
That commitment was in the batch rooted at R | the Merkle inclusion path |
R existed by block N | the on-chain memo transaction |
| The agent completed 412 verified runs | nothing here. That is Sable's signed assertion over its own metered ledger — see the credential's trust_model |
The last row is the important one. Anchoring makes Sable's assertions tamper-evident and time-stamped. It does not make Sable an oracle.
What is published
Three streams share one worker, one keypair, and one memo format. Each batch carries a domain-tagged discriminator so the streams can never be confused:
| Stream | Contents | Memo |
|---|---|---|
vault | Sable Vault event hashes | sable-vault:v1:<root> |
runs | agent-run head hashes | sable-runs:v1:<root> |
agents | agent-passport commitments | sable-agents:v1:<root> |
Nothing else is ever published. No prompts, no completions, no code, no wallet addresses, no account identifiers — a memo is a fixed prefix and a 64-character hex digest, and that is the whole payload (§3).
The worker runs every 10 minutes, on the leader replica only, and batches
up to 512 items per pass. A freshly minted passport is therefore honestly
awaiting anchor until the next pass — and stays that way if no anchor
backend is configured on the deployment. Sable never shows a signature it did
not obtain.
Re-minting a passport produces a new commitment, so a refreshed passport
returns to awaiting anchor until it is batched again. That is the intended
behaviour: the old commitment stays anchored and still verifies against the old
credential; the new one has not been witnessed yet, and says so.
Backends, and their honest status
| Backend | Status | What it publishes |
|---|---|---|
| Solana mainnet | Live when the operator's anchor keypair is set and funded. Batches with no keypair are simply not published — the item reports awaiting anchor. | An SPL-Memo transaction whose memo is <prefix>:v1:<root> |
| EigenDA (data availability) | Off unless configured (SABLE_EIGENDA_DISPERSER_URL). When unset, no blob is published, no row is written, and every surface says the backend is not enabled on this deployment. | The same memo bytes as a blob, via a disperser proxy |
| ERC-8004 EVM registry | Not deployed. There is no registry contract, no chain id, no address, and no on-chain agent id. | Nothing |
A root can therefore carry one anchor or two, and GET /v1/anchors/:root lists
every one it has, per backend, with per-backend verification instructions. Two
independent backends is the point: one backend is a single point of trust, and
two disagreeing is a signal.
About the EVM half
GET /v1/registry/identity/:handle returns an ERC-8004-shaped identity
document — agent id, owner address (when the passport discloses one), service
endpoint, the passport commitment, and the trust models Sable can actually back
with evidence. It is the off-chain half a registry entry would point at.
It is not an on-chain registration, and the response says so in its
erc8004 block, which is permanently "status": "not-deployed" and carries the
list of what deployment would require. Do not read the document as evidence of
an on-chain identity; there is none. What is published today is the
commitment, through the agents stream above.
Recompute a commitment
A passport commitment is sha256 over the credential's payload in a canonical
JSON form. The canonicalization is spelled out because a commitment nobody can
reproduce is worthless:
- Object keys sorted ascending by Unicode code point. (JavaScript's default string sort is UTF-16 code-unit order, which differs for astral-plane keys — compare code points explicitly.)
- Arrays in order. Order is meaning.
- Numbers: integers only. A non-integral number is an error, not a rounding: no two languages agree on a float's shortest round-trip form.
- Strings: UTF-8 verbatim, escaping only
"and\, the five short escapes (\b \f \n \r \t), and other control characters as lowercase\u00xx. Nothing else is escaped — no\uXXXXfor non-ASCII, no escaped forward slash. - No whitespace anywhere.
Then:
commitment = sha256_hex( canonical_json( JSON.parse( base64url_decode( credential ) ) ) )
import base64, hashlib, json
from sable_network import canonical_json # or write the five rules yourself
proof = ... # GET /v1/passport/{handle}/proof
raw = proof["credential"] + "=" * (-len(proof["credential"]) % 4)
payload = json.loads(base64.urlsafe_b64decode(raw))
commitment = hashlib.sha256(canonical_json(payload).encode()).hexdigest()
assert commitment == proof["commitment"]
Recompute a batch root
The agents stream uses a binary Merkle tree, so a passport holder gets a short
inclusion path instead of the whole batch. Leaf and node hashing are
domain-separated, and an odd trailing node is promoted unchanged — never
duplicated, because duplication is the classic construction that lets two
different leaf sets share a root.
leaf(c) = sha256( "sable-merkle-leaf-v1" || utf8(c) ) # c = the 64-char hex commitment
node(l, r) = sha256( "sable-merkle-node-v1" || l || r ) # l, r = 32 raw bytes
Fold the commitment through the published siblings, in order. A sibling with
"position": "left" means node(sibling, acc); "right" means
node(acc, sibling):
import hashlib
LEAF, NODE = b"sable-merkle-leaf-v1", b"sable-merkle-node-v1"
inc = proof["inclusion"]
acc = hashlib.sha256(LEAF + proof["commitment"].encode()).digest()
for s in inc["siblings"]:
sib = bytes.fromhex(s["hash"])
acc = hashlib.sha256(
NODE + (sib + acc if s["position"] == "left" else acc + sib)
).digest()
assert acc.hex() == inc["root"]
The vault and runs streams publish every hash in the batch, so their root is
a plain fold rather than a tree:
root = sha256( "sable-vault-batch-v1" || "|" || h0 || "|" || h1 || … )
GET /v1/anchors/:root returns the ordered leaves and states which of the two
constructions applies, so you never have to guess.
Read the anchor off-chain
Take the root, find its anchors, and check them yourself:
curl -s https://api.buildsable.com/v1/anchors/<root> | jq
For Solana, open the transaction signature on any explorer and read its SPL-Memo
instruction. Its bytes must be exactly sable-agents:v1:<root> (or the
vault/runs prefix for those streams). If they are, the root you recomputed
existed no later than that block.
Sable deliberately does not relay this step for you. A chain value fetched through Sable would prove nothing, so the verifier page marks the anchor check as "we cannot check this here" rather than showing a tick.
The four steps, end to end
GET /v1/passport/:handle/proof returns everything needed:
curl -s https://api.buildsable.com/v1/passport/my-agent/proof | jq
{
"handle": "my-agent",
"credential": "eyJ2IjoxLCJ0eXBlIjoiYWdlbnQtcGFzc3BvcnQi…",
"signature": "0x…",
"signer": "0x…",
"commitment": "9f2c…",
"anchor_state": "anchored",
"inclusion": {
"root": "4ab1…",
"stream": "agents",
"memo": "sable-agents:v1:4ab1…",
"leaf_index": 3,
"leaf_count": 17,
"siblings": [{ "position": "right", "hash": "…" }],
"anchors": [
{ "backend": "solana", "external_id": "5x…", "status": "anchored" }
]
}
}
- Signature — recover the EIP-191 signer over
base64url_decode(credential)and compare it to the address you pinned. - Commitment — canonicalize the decoded payload, sha256 it, compare to
commitment. - Inclusion — fold
commitmentthroughinclusion.siblings, compare toinclusion.root. - Anchor — read the memo for each entry in
inclusion.anchorsoff the public chain and confirm its bytes areinclusion.memo.
Steps 1–3 need nothing from Sable but the bytes in that response. Step 4 needs nothing from Sable at all.
In the SDKs
Both SDKs run steps 1–3 locally and report step 4 as unchecked, never as passed:
import { verifyPassportProof } from "@sable-network/sdk";
const result = verifyPassportProof(proof, PINNED_SIGNER);
// { ok, signatureValid, commitmentMatches, inclusionValid, anchored, checks: [...] }
from sable_network import verify_passport_proof # needs the [verify] extra
result = verify_passport_proof(proof, PINNED_SIGNER)
In both, a check that cannot be settled from the proof reports null /
None — never a silent pass.
No token, and nothing tradeable
Anchoring publishes hashes. It creates no token, no points, no emissions, and no on-chain balance; nothing here is payable, stakeable, gated, or transferable. Sable's compute is priced in dollars and paid in USDT, and none of that changes because a digest was written to a public ledger.
Related
- Agent Passport — what the credential asserts.
- Verifiable receipts — the per-request artifact and run chains.
- Sable Vault — the
vaultstream and its per-asset chains. - The independent verifier — paste an artifact, check it in-browser.