Tanilo
Docs
TaniloDocs

Anchoring: proof a receipt's payload existed by a given time

A receipt's signature shows which key signed it. An anchor shows that the receipt's canonical payload existed by the timestamp of a public block.

GET/v1/anchor/proof/sha256-<64 hex digits>Free during the beta.

The lookups under /v1/anchor answer at https://api.tanilo.io, with no key, rate-limited per network.

30-second summary

  • The hashes of queued receipts are collected into a Merkle tree, and the tree's 32-byte root is published in a transaction on GOAT Network. A receipt whose hash is in a batch has a short proof that its hash is under that root. Only hashes are involved.
  • An upper bound only. An anchor shows the canonical payload existed by the block's timestamp. It does not show how much earlier it existed, or when it was signed.
  • Verify the receipt first, as for every Tanilo receipt, and pass on the hash the verifier recomputes.
  • Not every receipt has a proof.
  • Batches currently run once a day. No anchoring interval is guaranteed.

What it shows, and what it does not

The time inside a receipt is the issuer's own statement. An anchor gives it an outside bound.

What an anchor shows: the receipt's canonical payload existed by the anchoring block's timestamp, under the hash and chain assumptions: that SHA-256 behaves as designed, and that the chain's record of that block and its timestamp is what it appears to be.

What it does not establish: when the signature was created or the receipt issued (the payload may be older than the block, and a signature over it can be made at any time); which key signed it (the signature shows that, and associating that key with an issuer requires a key you have authenticated as the issuer's); or that the claim in the receipt is true.

An anchor adds to a receipt; it replaces nothing. The anchor check alone does not check the receipt's signature, and it does not check that a hash corresponds to the receipt you hold. Verify the receipt first, as for every Tanilo receipt, and pass on the hash the verifier recomputes.

Coverage. Successfully queued receipt hashes are eligible for batching; a proof is available once anchoring and proof storage succeed. Queueing failures, backlogs and service interruptions can leave receipts without proofs.

Schedule. Batches currently run once a day. No anchoring interval is guaranteed.

How a batch is made

  1. Queue. When a receipt is signed, its canonical_sha256 and the time are put on a queue. Nothing else about the receipt is queued. Queueing is best effort: the receipt is returned whether or not its hash could be queued. A reply served from cache replays an earlier receipt and queues nothing.
  2. Batch. A scheduled run drains the queue: up to 1,000 hashes per batch, each hash once, in queue order.
  3. Tree. An RFC 6962 Merkle tree is built over the hashes with SHA-256. A leaf hash is SHA256(0x00 || digest) and a node hash is SHA256(0x01 || left || right).
  4. Root on-chain. The 32-byte root is published with one transaction to the anchoring contract. The contract records the block timestamp at which a root was first anchored, accepts roots from one publisher address only, and refuses a root that is already anchored.
  5. Proof. The batch is stored, and each hash in it then has a proof.

If a run fails before its transaction, the hashes stay queued and a later run takes them again.

Before a transaction is sent, the exact batch (its ordered hashes and its root) is saved. If a run stops after its transaction and before the batch is stored, the next run finishes that same batch first, with exactly the saved hashes, whatever has been queued since: it asks the contract whether the saved root is already anchored, finds that it is, rebuilds the record from the chain and sends no second transaction. Hashes queued in the meantime go into the next batch. This case, with new hashes arriving in between, is covered by a test in the service's test suite.

The proof: tanilo.anchor.v1

One small JSON record per anchored hash: the path from the hash to the root, and where the root was published. This is the real proof of a test receipt issued by /v1/verify-facts on 2026-10-06, as returned by the lookup:

JSON
{
  "anchor_version": "tanilo.anchor.v1",
  "leaf": "sha256-67d3ddb5888ac75266974c3277987556fe5a36df97d32d014f6a5f279384c51f",
  "leaf_hash_alg": "rfc6962-sha256",
  "leaf_index": 4,
  "tree_size": 5,
  "path": [
    "8743b89ca3a36611687e29273406bf3589e15e0e35c4e0e50b4700f49f6129ea"
  ],
  "root": "326302795a1ce8be3447e02a30158e43b0ab7bded209eaeb3036cff340318bc7",
  "batch_id": "batch-326302795a1ce8be3447e02a30158e43",
  "batched_at": "2026-10-06T00:36:54.707Z",
  "anchors": [
    {
      "kind": "evm-contract",
      "chain": "eip155:2345",
      "chain_id": 2345,
      "contract": "0xddCC4eb18b39a520b874046b91b748B5E8cE7C54",
      "publisher": "0x1C17bde3592DEa74Ef18c74061BF0E77E300aF96",
      "tx_hash": "0x8d5d2db89fe694d1b04c98f72bdf961876008b79bb1c6864047025984233ffce",
      "block_number": 15874006,
      "block_hash": "0xb24f9803534067d8461b9df432feeb0106900212bab328e77b27921feb774466",
      "block_time": "2026-10-06T00:36:56.000Z",
      "gas_used": "71792",
      "effective_gas_price_wei": "130007",
      "fee_native": "0.000000009333462544",
      "explorer_tx": "https://explorer.goat.network/tx/0x8d5d2db89fe694d1b04c98f72bdf961876008b79bb1c6864047025984233ffce"
    }
  ]
}
MemberMeaning
leafThe canonical_sha256 the proof is for. When you check a proof, compare against the hash your own verifier recomputed from the receipt, not against this member.
leaf_index, tree_size, pathThe RFC 6962 audit path. With the leaf, they are enough to recompute the root offline.
rootThe Merkle root of the batch, as lower-case hex.
batch_idbatch- followed by the first 32 hex digits of the root.
batched_atWhen the batch was built, by the service's clock. Informational. It is not evidence of time.
anchors[].contract, chainWhere the root was published. A checker asks only a contract you trust.
anchors[].block_time, tx_hash, block_number, block_hash, publisher, gas_used, fee_nativeWhat the service recorded about its transaction. Informational: the checker does not verify these members. The timestamp that counts is the one the contract returns for the root.

A checker ignores an anchor kind it does not know: that entry is indeterminate, not a failure.

Check this one yourself

The proof above belongs to a test receipt whose canonical_sha256 is sha256-67d3ddb5888ac75266974c3277987556fe5a36df97d32d014f6a5f279384c51f. The commands below verify the receipt's signature, recompute the path offline, and then ask the contract on GOAT Network when the root was anchored. They need Python 3, pip and curl.

shell
python3 -m pip install tanilo-receipt-verify==0.2.0
curl -sS -O https://raw.githubusercontent.com/TKCollective/tanilo-anchor/e11d8c616246ceccbe03d46217308d46c7b2cb81/examples/mainnet-2026-10-06/receipt.json
curl -sS https://tanilo.io/.well-known/jwks.json -o jwks.json
curl -sS https://api.tanilo.io/v1/anchor/proof/sha256-67d3ddb5888ac75266974c3277987556fe5a36df97d32d014f6a5f279384c51f -o proof.json

The checker is the pinned 0.2.0 release of the package, and the receipt is downloaded from one fixed commit of the public repository, so neither can change under you. Save this as check.py in the same folder:

Python
import json
from tanilo_receipt_verify import verify, verify_anchor, evm_contract_lookup

CONTRACT = "0xddCC4eb18b39a520b874046b91b748B5E8cE7C54"   # the contract you trust

receipt = json.load(open("receipt.json"))
jwks = json.load(open("jwks.json"))      # a key set you have authenticated as the issuer's
proof = json.load(open("proof.json"))["proof"]

# 1. The signature, and the hash recomputed from the signed payload.
r = verify(receipt, jwks_by_issuer={"https://tanilo.io/.well-known/jwks.json": jwks})
print("signature:", r.status, "| canonical_sha256 recomputed by the verifier:", r.canonical_sha256)
assert r.status == "valid"

# 2. Offline: the path from the verifier's hash (never the proof's own leaf) to the proof's root.
print("path verifies offline:", verify_anchor(r.canonical_sha256, proof).merkle_ok)

# 3. Against the chain: the trusted contract's current anchoredAt(root), as the RPC endpoint reports it.
lookup = evm_contract_lookup("https://rpc.goat.network", trusted_contracts=[CONTRACT], chain_id=2345)
a = verify_anchor(r.canonical_sha256, proof, {"evm-contract": lookup})
print(a.status, a.anchored_at)

Run python3 check.py. Run on 2026-10-06, it printed:

output
signature: valid | canonical_sha256 recomputed by the verifier: sha256-67d3ddb5888ac75266974c3277987556fe5a36df97d32d014f6a5f279384c51f
path verifies offline: True
anchored 2026-10-06T00:36:56Z

The hash passed to the anchor check is the one the verifier recomputed from the receipt's signed payload, never the proof's own leaf: a proof always agrees with itself. Step 2 needs no network. Step 3 makes two JSON-RPC calls to https://rpc.goat.network.

Downloading the key set from tanilo.io is a convenience for this example. It is not, by itself, authentication of the issuer's key.

This example's proof shows its batch held five hashes (tree_size).

What the chain check relies on

The online check asks the RPC endpoint you configured for the trusted contract's current anchoredAt(root), and reports the timestamp it returns. So it relies on that endpoint's answer.

  • It does not independently verify the transaction metadata a proof carries: the transaction hash, block number and hash, publisher, or the gas and fee figures.
  • It does not establish finality. A chain reorganization can change or remove a confirmation.
  • It asks only the contract addresses you give it, never one the proof names by itself. The timestamp is read from a contract, and an unknown contract could report any timestamp it likes.
OutcomeMeaning
anchoredThe path verifies and the contract you trust holds the root. anchored_at is the block timestamp the contract recorded.
not_anchoredThe path does not verify, or the contract was asked and does not hold the root.
indeterminateThe path verifies but publication was not confirmed: no lookup was supplied, the RPC could not be reached or serves another chain, or the proof names a contract you did not list.

Which verifier. verify_anchor and evm_contract_lookup ship in tanilo-receipt-verify 0.2.0 (published 8 October 2026) as the module tanilo_receipt_verify.anchor, which needs only Python's standard library. It is a byte-identical copy of python/tanilo_anchor_verify.py in the public repository TKCollective/tanilo-anchor at commit e11d8c6; that single file still works on its own. The tree code the service runs, the contract and the test vectors are in that repository. Version 0.1.2 of the package checks signatures only.

Anchor status sits beside signature validity and is never folded into it. An anchored receipt with a bad signature is still invalid. A valid receipt with no anchor is still valid; it has no time bound.

Looking up a proof

Use the canonical_sha256 returned with the receipt.

shell
curl -sS https://api.tanilo.io/v1/anchor/proof/sha256-<64 hex digits>
AnswerMeaning
200, "status": "anchored"The proof is in proof. A stored proof does not change.
404, "status": "no_proof"No proof is stored for this hash. A recently queued hash may not be anchored yet. A hash that was never queued, or whose queueing failed, has no proof either. The cases look the same in this answer.
400, "error": "invalid_hash"The value is not sha256- followed by 64 lower-case hex digits.

The batch index and the batch document

GET /v1/anchor/batches lists batches, newest first, with batch_id, root, tx_hash, block_time and a link to the transaction. limit (up to 100) and before page through it. GET /v1/anchor names the network and the contract in use.

GET /v1/anchor/batches/{batch_id}?leaf={canonical_sha256} returns the batch document, the ordered list of hashes in that batch, to a caller who names a hash that is in it. With it, a holder can rebuild every proof in that batch without asking Tanilo again: build the RFC 6962 tree over leaves in order, and compare the root with the one the contract holds. Without a hash from the batch the answer is 404.

The contract

NetworkChainContract
GOAT Networkeip155:23450xddCC4eb18b39a520b874046b91b748B5E8cE7C54
  • One publisher. Only one address can anchor roots. That shows which address published a root. It does not show who issued a receipt.
  • First time only. A root that is already anchored is refused, so the timestamp recorded for a root cannot be overwritten.
  • Roots only. A transaction carries the root, a batch id and a count field. No receipt hash and no receipt content goes on-chain. The service submits 0 in the count field.
  • One call to check. anchoredAt(root) returns the block timestamp, or zero if the root was never anchored.

The contract was deployed on 2026-10-06 in transaction 0xb05be04587d5b55778163f0c100504f8c3c0d945528099c2b4564dcfe7c1afb7. Its source, its build and the way to compare the build with the code at the address are in the public repository.

GOAT's own documentation calls this network "Alpha Mainnet".

What is stored, and what is public

  • Stored: the receipt's canonical_sha256 and the time it was queued; for each batch, the ordered list of hashes, the root and the transaction details; for each hash, which batch it is in and at which position.
  • Not stored for anchoring: claim text, check input, or the receipt. None of them goes into the queue, a batch, a proof or a transaction.
  • Batch sizes. Batch counts are omitted from the public index and the on-chain count field is set to zero. Anyone with a hash from a batch can retrieve its size and ordered hash list, so batch sizes are not confidential.

The times at which batches exist are visible to everyone.

Timing and limits

  • Schedule. Batches currently run once a day. No anchoring interval is guaranteed.
  • An upper bound only. An anchor shows the canonical payload existed by the block's timestamp. It does not show how much earlier it existed, or when it was signed.
  • Not every receipt has a proof. Successfully queued receipt hashes are eligible for batching; a proof is available once anchoring and proof storage succeed. Queueing failures, backlogs and service interruptions can leave receipts without proofs.
  • Receipts signed before anchoring started (2026-10-06) may have no anchor.
  • Block timestamp. The timestamp is the chain's own statement of when the block was made.
  • The network is labelled Alpha. GOAT's own documentation calls this network "Alpha Mainnet". If the chain cannot be reached when a batch is due, the hashes wait for a later run. If the network or its historical state becomes unavailable, live confirmation may be impossible; the offline inclusion check still works, and previously preserved chain evidence may remain usable.
  • The chain has to be reachable to confirm. The path can always be recomputed offline. Confirming that the root was published needs an RPC endpoint for the network.

Not in this version

A second anchor that does not depend on GOAT; independent verification of a proof's transaction metadata, or of finality, by the checker; and a batch statement signed by Tanilo's receipt key, which would tie a batch to the issuer's key and not only to the publisher address.

Availability

Anchoring on GOAT Network started on 2026-10-06 for receipts from /evaluate and /v1/verify-facts. Successfully queued receipt hashes are eligible for batching; a proof is available once anchoring and proof storage succeed. Queueing failures, backlogs and service interruptions can leave receipts without proofs. The lookups are free during the beta, with no key, rate-limited per network. See pricing and deterministic mode.