chain-anchor-v1 — receipt-format-agnostic public-chain anchor reference
Terminology. The canonical vocabulary on every surface is proof (grouped in folders); API responses emit the canonical names only (proof_id, proof_url, folder_slug). Where this spec shows receipt / matter / bundle_id, it is documenting a frozen on-disk/on-chain format, a stable filename (RECEIPTS.md), or a legacy route that remains accepted inbound — proof and receipt denote the same record. Full alias map: the compatibility map. In this spec, receipt most often refers to the external attestation format being wrapped (AAR, RFC 3161, C2PA) — not the Satsignal proof record; the surrounding text makes the sense clear.
This is the interop specification. For the user-facing overview, see satsignal.cloud/docs.html.
Status: draft 1, 2026-05-12. Audience: anyone shipping an AI-agent receipt format (AAR, RFC 3161-wrapped, C2PA, consent-receipt, custom JSON) who wants a second, issuer-independent proof root. Goal: define a tiny canonical object any receipt format can embed to point at an immutable public-chain commitment of the same payload, so a verifier can fall back to the chain if the issuer's signing key, TSA cert, or hosted ledger ever becomes untrusted or unavailable.
The field is intentionally generic. Satsignal is the reference implementation; any anchor provider that writes a root hash into a public chain can fit the same shape.
1. Why this exists
Every receipt format in the agent-receipt space (Mar 2026 survey) bottoms out in one trust root: an issuer Ed25519 key, a TSA X.509 chain, a private L2's validator set, or a vendor's hosted verifier. Each is a single point of revocation, compromise, or shutdown. A chain_anchor field lets the same receipt carry a second, independent proof — anchored in a public chain that the issuer does not control — without forcing the receipt format to change anything else.
One field. No SDK dependency. Verifiable offline with a block explorer and stdlib hashing.
2. The object
Canonical JSON. Required fields first; verifiers MUST reject if any required field is missing or malformed.
{
"v": 1,
"system": "satsignal",
"chain": "bsv-mainnet",
"txid": "<64-hex>",
"root_hash": "<64-hex, sha-256>",
"category": "commitment",
"height": 891245,
"block_hash": "<64-hex>",
"anchor_id": "anc_01HXYZ...",
"workspace": "<workspace-scope>",
"issued_at": "2026-05-12T18:00:00Z"
}
Required
| Field | Type | Meaning |
|---|---|---|
v | int | Spec version. 1 for this document. |
system | string | Anchor provider identifier, lowercase. satsignal for Satsignal-issued anchors; other providers MAY register their own (see §7). |
chain | string | Public chain identifier, lowercase, hyphenated. bsv-mainnet, bsv-testnet, btc-mainnet, eth-mainnet. |
txid | string | Lowercase hex transaction id on chain. |
root_hash | string | Lowercase hex full SHA-256 of the merkle/semantic root the anchor commits to. Verifiers MUST check it against the on-chain commitment — directly where the chain envelope carries the full root, or indirectly where the envelope carries a hash of the provider's canonical document and root_hash lives inside that document (the Satsignal case). See §5 for both paths. Note the on-chain bytes are not always this value verbatim: Satsignal's envelope commits a 20-byte truncated hash of a canonical doc, and root_hash is the full root read out of that verified doc. |
Optional
| Field | Type | Meaning |
|---|---|---|
category | string | What is being anchored, in the provider's taxonomy. For Satsignal: one of commitment, evidence_bundle, policy_snapshot, reveal. Other providers define their own values. Verifiers that don't recognize the value MUST treat the anchored payload as opaque and rely on the outer receipt for interpretation. |
height | int | Block height. Convenience for cheap "is it buried yet?" checks. |
block_hash | string | Lowercase hex block hash. Enables SPV-style verification without trusting an explorer. |
anchor_id | string | Provider-scoped opaque id, useful for fetching extended metadata via the provider's API (never required to verify). |
workspace | string | Provider-scoped issuer scope. For Satsignal, the workspace slug. |
issued_at | string | RFC 3339 UTC timestamp the anchor was issued. Informational; the chain's block time is authoritative. |
Unknown fields MUST be ignored, not rejected, so providers can extend the object without breaking existing verifiers.
3. String form (compact)
For HTTP headers, query strings, log lines, or any place a single token is more practical than a JSON object:
chain-anchor:1:satsignal:bsv-mainnet:<txid>:<root_hash>
Colon-separated, lowercase, no whitespace. Field order is fixed: chain-anchor, v, system, chain, txid, root_hash. Optional fields are not representable in the string form — use the JSON object when you need them. A verifier MUST accept either form and produce the same result.
4. Where to embed it
The spec defines the shape, not the placement. The slots below are non-normative integration patterns, not recommendations endorsed by the named projects. They do not imply adoption, partnership, or endorsement by AAR/BotIndex, the RFC 3161 ecosystem, C2PA, AffixIO, Visa, NVNM, or any other party — each merely shows how a public-chain anchor can be carried alongside that format without changing the format's own authority model:
- AAR (BotIndex): top-level optional
chain_anchorfield on the receipt, included in JCS canonicalization so the issuer's Ed25519 signature also covers the anchor reference. (This JCS + Ed25519 is the embedding issuer's signature scheme over its own receipt — not a Satsignal canonicalizer. Satsignal neither produces nor verifies it; its role is the on-chaindoc_hashand merkle inclusion in §5, computed with SCJ-v1, see/spec-mbnt.) - RFC 3161 / TSA-timestamped receipts: parallel proof on the same receipt envelope. Receipt is dual-attested: TSA token +
chain_anchor. Some regulated workflows require or prefer RFC 3161 timestamp tokens; there,chain_anchorsits beside the TSA token rather than replacing it, and long-tail verification falls back to the chain. See §4.2 for the embedding shape and verification recipe. - AffixIO consent receipts:
proof.chain_anchoron the consent object; the anchor points to the agent run that consumed the consent. - C2PA: custom assertion label
com.chain-anchorcontaining the JSON object verbatim. The trailing.vNis intentionally omitted from the label — c2pa-rs treats.vNsuffixes as a separate version field and normalizes them away during storage, so an in-label version reads back stripped. The envelope's ownvfield (§2) carries the spec version; readers MUST trust that rather than the label suffix. A worked example with a real ES256-signed manifest carrying this assertion (cert intentionally not in the C2PA production trust list, so the cert path fails while the chain path verifies) is at/samples/chain-anchor/c2pa-credentialed.jpgwith the matching envelope at/samples/chain-anchor/c2pa-credentialed.json. Co-exists with X.509-signed manifests. - Visa Trusted Agent Protocol (TAP): TAP's wire format is RFC 9421 HTTP Message Signatures over agent-merchant requests, with agent identity verified against a registry-published public key. A chain anchor can carry the agent's registration record and any number of specific signed requests as batched leaves under one on-chain root, giving merchants and auditors a permanent record of (a) what key the agent was registered under at time T and (b) what specific requests the agent signed at time T — neither of which the TAP registry's then-state needs to remain online to verify. A worked example with a real Ed25519-signed RFC 9421 request and a TAP-style registration record batched under one BSV anchor is at
/samples/chain-anchor/tap-interop.jsonwith the reproduction script at/samples/chain-anchor/tap-demo.py. Built from the public RFC 9421 standard; no code redistributed from Visa's reference repository. - NVNM Chain checkpoints: each NVNM block batch publishes a single
chain_anchorto BSV; the L2 block header carries the reference. - Generic JSON: top-level
chain_anchorkey.
When a receipt carries more than one anchor (e.g. a batched evidence bundle anchored both in Satsignal and a partner chain), use chain_anchors: [ ... ] instead. Verifiers MUST accept both shapes.
4.1 Batched anchoring
Many useful anchoring patterns commit multiple receipts under a single chain transaction — e.g. an L2 checkpointing its hourly block roots into one BSV transaction, an AAR issuer batching receipts to commit multiple receipts under a single anchor, a TSA-style service committing a batch of client tokens. In all of these, the committed root_hash is a merkle root over N leaves, and each leaf is the content hash of one participant.
A chain_anchor carried by an individual receipt in such a batch SHOULD include two additional optional fields:
| Field | Type | Meaning | |
|---|---|---|---|
leaf_hash | string | Lowercase hex SHA-256 of the canonical content of this receipt — the leaf this receipt occupies in the merkle tree. | |
inclusion_proof | array | Ordered path from the leaf to the on-chain root, per SPEC_merkle_row.md: each entry is `{"sib": "<64-hex>", "side": "L" | "R"}`. Leaf-level entries first, root-level entries last. |
Verification adds two steps to §5 between the existing steps 3 and 5:
- 3a. Recompute the receipt's content hash and check it equals
leaf_hash. Mismatch → reject. - 3b. Walk
inclusion_prooffromleaf_hash. For each entry, ifside == "L"setcarry = sha256(sib || carry), elsecarry = sha256(carry || sib). The finalcarryMUST equalroot_hash. Mismatch → reject.
When leaf_hash and inclusion_proof are both absent, the receipt is the entire anchored payload (the single-receipt case in §5). When both are present, the receipt is one leaf in a batch.
Two worked examples are hosted at /samples/chain-anchor/:
batch-anchor.json— four AAR-shaped leaves batched under one real BSV anchor (txid1ba57f41…15783e, root1c724e6fb05e7355…b861d0989). Homogeneous receipts, single issuer — the one-anchor-over-multiple-receipts pattern.cross-domain.json— three leaves drawn from three independent public systems (Ethereum mainnet block hash, HuggingFace model card README at a pinned commit, C2PA-credentialed test image) batched under one BSV anchor (txid027c5b69…f075, root686c620b9b0e7219…f3ebfb7). Heterogeneous evidence — the shape this spec promises but the AAR-only sample doesn't yet demonstrate.
Both samples include stdlib-only reproduction recipes; the existing /verify page resolves the txid, extracts the on-chain commitment (the MBNT doc_hash), re-derives the canonical document's root, and walks the proof end-to-end.
4.2 Dual-attest with RFC 3161
Some regulated workflows require or prefer RFC 3161 timestamp tokens; in those workflows chain_anchor can sit beside the TSA token rather than replace it.
The dual-attest pattern: the receipt envelope carries both
- a TSA-issued RFC 3161 token over
sha256(canonical_receipt), and - a
chain_anchorwhoseroot_hasheither equalssha256(canonical_receipt)(single-receipt anchor) or includes it as a leaf in a batched anchor per §4.1.
The two attestations have independent trust roots and independent failure modes:
| Path | Trust root | Failure modes |
|---|---|---|
| TSA token | TSA's X.509 certificate chain + TSA's continued operation | Cert chain expires; CA revoked; TSA service shut down or unreachable; cert pinning policy diverges |
| chain_anchor | Public chain itself (block headers + PoW/consensus) | Chain reorganization below the verifier's confirmation policy; chain/fork-policy dispute |
A verifier MAY accept either attestation as sufficient. Receipts are typically verified through both paths in parallel; long-tail verifiability (years after issue, when TSA infrastructure has rotated keys multiple times) leans on the chain path. Short-tail compliance check ("did a trusted timestamping authority sign this when it was issued?") leans on the TSA path.
Embedding shape. For receipt formats with a typed evidence array (such as AAR's evidenceRef[]), each attestation is its own entry — rfc3161/v1 for the TSA token and chain-anchor/v1 for the chain anchor. The rfc3161/v1 entry carries tsaToken (base64-encoded TimeStampResponse, ASN.1 DER per RFC 3161 §3.3), tsa (TSA identifier), and messageImprintHex (the canonical hash the token was issued over). For receipt formats without a typed array, the spec recommends sibling fields tsa_token + chain_anchor on the receipt envelope.
Order-of-operations subtlety. The TSA token is issued over the canonical receipt at a specific point in time; canonicalization for the TSA's message imprint MUST therefore EXCLUDE the eventual rfc3161/v1 entry (and any signature byte field — for AAR, that's signature.sig per §5.1 of the AAR spec). A verifier reconstructs the canonical bytes by stripping those fields, hashing, and comparing against the token's messageImprint.
Verifier rules.
- If only one of {TSA,
chain_anchor} verifies, the verifier SHOULD report success with a note indicating which path failed and why. Single-path success is still strong evidence. - If both fail, the verifier reports failure (the receipt's own signature still gives issuer attribution but no third-party timestamp).
- If both succeed and their attested timestamps disagree by more than a verifier-policy threshold (e.g. 1 hour), treat as suspicious — divergence is expected (TSA timestamps are seconds-precision; chain timestamps are block-time, often minutes delayed) but large gaps suggest stale or tampered data.
A worked example — the same AAR receipt as §4.1, now with both a real RFC 3161 token from freetsa.org and the real BSV anchor — is hosted at /samples/chain-anchor/aar-receipt-2-dualattest.json. RECEIPTS.md includes an openssl ts -verify recipe for the TSA path. Both paths verify independently.
5. How to verify (stdlib-only)
Given a receipt and its embedded chain_anchor:
- Fetch the transaction at
txidfrom any node, explorer, or local copy of the chain. No provider API call required. - Extract the on-chain commitment from the transaction's data output, per the provider's anchor format. Providers fall into two shapes:
- Envelope carries the root directly. Read the committed root straight out of the data output and go to step 3.
- Envelope carries a hash of a canonical document (the Satsignal case). The Satsignal
OP_FALSE OP_RETURNlayout inSPEC_mbnt.mdcommits a 20-bytedoc_hash = sha256(canonical_doc)[:20]— not a 32-byte root. Obtain the provider's canonical document (Satsignal ships it in the.mbntbundle) and confirmsha256(canonical_doc)[:20]equals the on-chaindoc_hash. Theroot_hashis then either the fullsha256(canonical_doc)itself — the on-chaindoc_hashis its 20-byte prefix (single-document anchor, per §4.2) — or a merkle/semantic root carried as a field inside that now-verified document (manifest / batched anchor, per §4.1).
- Check the
chain_anchor'sroot_hashmatches the root established in step 2 — the value read directly from the envelope, or the root read from the verified canonical document. Mismatch → reject. - Check confirmations meet the verifier's policy. Below policy → treat as pending, not verified.
- Verify receipt content against
root_hashusing whatever inclusion proof the receipt format carries (merkle path, single-leaf hash, full-payload re-hash). Satisfied → the receipt is anchored.
No SDK, no account, no network call to the issuer. The verifier needs: a way to read a transaction, SHA-256, and the receipt's own inclusion proof scheme.
What anchoring proves
A successful verification establishes that the issuer of the receipt knew root_hash at or before the block time of txid. It does not prove the underlying content existed before that time. Adopters SHOULD NOT market chain_anchor as "proof of existence." The full, canonical statement of the guarantee — tamper-evidence and an upper time bound, not authorship and not prior existence — is in the bundle spec; this paragraph is the chain-anchor-v1-scoped restatement of it.
6. Worked example — AAR receipt with chain_anchor
{
"v": "aar-1.0",
"issuer": "did:web:botindex.io",
"agent": "did:agent:abc",
"action": { "type": "purchase", "amount_cents": 4200, "merchant": "acme" },
"timestamp": "2026-05-12T17:59:58Z",
"nonce": "0f3c...",
"chain_anchor": {
"v": 1,
"system": "satsignal",
"chain": "bsv-mainnet",
"txid": "a3f1...e29c",
"root_hash": "7b91...44d2",
"category": "commitment",
"height": 891245,
"anchor_id": "anc_01HXYZABCDEF"
},
"signature": "ed25519:..."
}
The Ed25519 signature covers the entire object (JCS-canonicalized by the issuer — their canon, not Satsignal's), including chain_anchor. A verifier with a stale or revoked issuer key can still establish that the receipt's root_hash was committed on BSV at height 891245.
7. Versioning and provider registry
v: 1is this document.- Additive optional fields do not bump the version.
- Renaming, removing, or changing semantics of required fields requires
v: 2and a new field name (chain_anchor_v2) to avoid ambiguity in co-existing receipts. - New
systemorchainvalues do not require a version bump; they are values, not schema.
Provider registry. The only currently recognized system value is satsignal. Additional providers are registered by pull request against this document. A registry entry is one line: the lowercase identifier and a link to the provider's anchor-envelope spec (equivalent of Satsignal's SPEC_mbnt.md), so that step 2 of §5 is unambiguous for verifiers.
8. Reference implementation
Available now. No library is required to verify — §5 is a complete stdlib-only recipe (sha256 + a single chain fetch). The in-browser verifier at /verify also accepts a pasted receipt carrying chain_anchor and reports each of the 5 verification steps independently.
Planned (not yet published). Packaged stdlib-only helpers — chain_anchor.py (Python) and chain-anchor.js (browser + Node) for encode / decode / verify against a fetched BSV transaction — are forthcoming. They are conveniences only: this document is the wire-level contract and §5 already lets any implementer verify without them. This section will link them once published; until then there is deliberately no file at those names to fetch.
Questions about this specification? Email hello@satsignal.cloud.