Portal
Documentation: all sections

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:

ClaimProven by
This credential was issued by this Sable deploymentthe EIP-191 signature, checked against a pinned signer
This credential is byte-for-byte the one that was committedthe commitment: canonical JSON → sha256
That commitment was in the batch rooted at Rthe Merkle inclusion path
R existed by block Nthe on-chain memo transaction
The agent completed 412 verified runsnothing 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:

StreamContentsMemo
vaultSable Vault event hashessable-vault:v1:<root>
runsagent-run head hashessable-runs:v1:<root>
agentsagent-passport commitmentssable-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

BackendStatusWhat it publishes
Solana mainnetLive 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 registryNot 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:

  1. 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.)
  2. Arrays in order. Order is meaning.
  3. 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.
  4. 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 \uXXXX for non-ASCII, no escaped forward slash.
  5. 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" }
    ]
  }
}
  1. Signature — recover the EIP-191 signer over base64url_decode(credential) and compare it to the address you pinned.
  2. Commitment — canonicalize the decoded payload, sha256 it, compare to commitment.
  3. Inclusion — fold commitment through inclusion.siblings, compare to inclusion.root.
  4. Anchor — read the memo for each entry in inclusion.anchors off the public chain and confirm its bytes are inclusion.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.