Graph Privacy Showcase

Encrypted anchor stream

A live demonstration that client-side-encrypted records, anchored on public Ethereum as nothing but calldata, are indexable as a complete, ordered, independently re-derivable stream — existence, ordering, completeness, without plaintext. Contract on Ethereum Sepolia; subgraph published to The Graph's decentralized network. Status as of 2026-07-29.

What this is

Institutions that keep sensitive records off-chain — compliance logs, audit trails, transaction records — anchor them on-chain so they become tamper-evident: each record's ciphertext is hashed, and the hash is posted to a public chain the record-keeper cannot rewrite. The EthSystems Institutional Privacy Map describes this in its encrypted off-chain audit and compliance monitoring patterns: anchored logs are the substrate disclosures and screening workflows are checked against. Both patterns presuppose an unnamed serving leg — a party who serves the complete, ordered anchor set to the verifier, and whom the verifier does not have to take on faith.

This PoC builds that leg and makes it checkable. An anchor-writer CLI encrypts ten fictional records client-side and posts their digests to a no-op contract on Sepolia — the anchors exist only as transaction calldata; the contract stores nothing and emits nothing. A call-handler subgraph, published to The Graph's decentralized network, decodes those transactions into an index of existence, ordering, and completeness. A completeness checker then reconciles a disclosure (plaintext records plus the stream key) against the served index and against raw Sepolia blocks — and fails loudly, with a named finding, when a record is restated, a record is withheld, a server withholds anchors, or the hash chain breaks.

The contract follows the "Data Edge" shape from The Graph's own ecosystem (GIP-0025, catalogued as GRC-0001): meaning lives entirely in calldata, and a subgraph gives it structure. The problem statement is EthSystems'; the anchoring pattern precedent is The Graph's; the code here is ours.

How it works

The contract is deliberately almost nothing.

contract AnchorDataEdge {
    function postAnchor(bytes calldata) external {
        // no-op
    }
}

No storage, no events, no owner. It is permissionless by design: anyone may post to any stream, and the index reports what the chain contains — the verification tooling adjudicates.

The envelope. Each anchor is a fixed 105-byte payload: a version byte, a random 32-byte stream id, a big-endian sequence number (contiguous from 0), a keccak256 digest of the record's ciphertext, and a keccak256 digest of the previous anchor's full envelope. That last field hash-chains the stream: suppressing, reordering, or forging a middle anchor breaks both sequence contiguity and the chain. Digests are of ciphertext, never plaintext — nothing about record content is derivable on-chain.

Deterministic encryption, so a disclosure is checkable. Records are encrypted client-side with AES-256-GCM-SIV, with the per-record nonce and key derived via HKDF from the stream key, and the stream id and sequence number bound into the additional authenticated data. Determinism is the point: plaintext plus stream key reproduce every ciphertext byte-for-byte, so a verifier can recompute the entire expected anchor sequence from a disclosure alone — no ciphertext archive needed. A ciphertext transplanted to another position fails authentication.

The subgraph indexes pure calldata. A call handler on postAnchor(bytes) fires on every matching transaction (indexers extract calldata from execution traces, since there are no events to filter on) and decodes the envelope with fixed-offset reads. The index keeps honest-completeness rules: a well-formed first occurrence of a (stream, seq) pair becomes an Anchor; an identical re-submission increments a duplicate counter; a different envelope at an existing seq becomes a ConflictingAnchor and flags the stream; an undecodable payload becomes a MalformedAnchor with its raw bytes. Nothing is silently dropped. The canonical stream makes this visible on purpose: 10 anchors (seq 0–9) plus one deliberate junk payload and one deliberate re-submission, both publicly reported by the index as notes, not hidden.

The checker closes the loop. completeness-checker verify recomputes the expected sequence from the disclosure, fetches the served index (through the gateway, from a local graph-node, or — with no component from The Graph in the trust path at all — rebuilt from raw Sepolia blocks), and compares. Every verdict is specific and carries its own exit code:

Lie Finding
A disclosed record was restated after anchoring ALTERED seq=k — its recomputed digest no longer matches the anchor
A record was withheld from the disclosure MISSING seq=k plus a count mismatch — the chain anchors something the disclosure omits
A server withholds anchors GAP seq=k — the served index lacks an anchor the raw chain has
A server (or forger) breaks the sequence CHAIN-BREAK seq=k — the envelope hash chain no longer connects
A third party posts a different envelope at an existing seq CONFLICT — possible on a permissionless contract, and reported as exactly what it is

The direction of trust is stated in the tool's output: the on-chain anchor set is the reference, and disclosures are checked against it.

The Graph's role, precisely

What The Graph does here:

What The Graph does not do here:

Reproduce it yourself — on the network

This is the verify-only path against the canonical deployment: you deploy nothing and post nothing. You need the code repository, Node 22 with pnpm, jq and curl, Foundry's cast (for the raw-calldata check below), a Sepolia archive RPC URL of your own, and an API key created in Subgraph Studio.

Canonical values (address book of record: deploy/canonical.json in the repository):

Subgraph deployment id QmWcifKxjEKSg1nVerGXjmF5jbydj4RKtQgVvvxJBFyVs6
Subgraph id (Explorer) 46mk4GwpMkQEsR5UG3mFwSmYVU7ii838cvQGNt5WEhFb
AnchorDataEdge (Sepolia) 0x88AE18d29C20267441C5393787f719fa3f334Dcb
Canonical stream id 0x0c0bf6a287a135b35900881fe941de67087970677cb4153c8d02961c9970e1ef

One gate, stated plainly: the checker reconciles a disclosure — plaintext records plus the stream key — and the canonical disclosure bundle is not yet published (it contains the stream key; once it is public, anyone can post conflicting anchors at existing seqs, which the checker reports as CONFLICT — the tool working, not the demo breaking). Until that publish decision, the checker's reconciliation steps run only with a bundle obtained from us; everything else on this page is runnable by anyone today.

Query the served index directly (the subgraph's Explorer page also has a zero-setup playground):

curl -s https://gateway.thegraph.com/api/deployments/id/QmWcifKxjEKSg1nVerGXjmF5jbydj4RKtQgVvvxJBFyVs6 \
  -H "authorization: Bearer $GRAPH_API_KEY" -H 'content-type: application/json' \
  -d '{"query":"{ streams { id anchorCount latestSeq headEnvelopeDigest hasConflicts } anchors(first:10, orderBy:seq){ seq ciphertextDigest prevEnvelopeDigest txHash } malformedAnchors { txHash } }"}'

Expect 1 stream, 10 anchors at seq 0–9, one malformed anchor and a duplicate count — exactly as seeded. Every served anchor names its txHash; for any one of them, cast tx <txHash> input --rpc-url <your Sepolia RPC> returns the raw calldata whose bytes after the 68-byte ABI header are exactly the 105-byte envelope the index decoded. The index adds structure, never facts.

Build the checker and pin a block (run from items/02-encrypted-anchoring in the repository; unpinned gateway runs are not reproducible, so every run below passes --block):

pnpm install && pnpm build

DEPLOYMENT=QmWcifKxjEKSg1nVerGXjmF5jbydj4RKtQgVvvxJBFyVs6
export GRAPH_API_KEY=…   # from Subgraph Studio; environment only
DISCLOSURE=…             # path to the disclosure bundle (see the gate above)

BLOCK=$(curl -s "https://gateway.thegraph.com/api/deployments/id/$DEPLOYMENT" \
  -H "authorization: Bearer $GRAPH_API_KEY" -H 'content-type: application/json' \
  -d '{"query":"{_meta{block{number}}}"}' | jq -r .data._meta.block.number)

Reconcile through the gateway, capturing the attestation:

node dist/completeness-checker/cli.js verify --disclosure "$DISCLOSURE" \
  --source gateway \
  --url "https://gateway.thegraph.com/api/deployments/id/$DEPLOYMENT" \
  --api-key "$GRAPH_API_KEY" \
  --block "$BLOCK" \
  --evidence .local/attestations

Expect exit 0, with two notes and no findings: the seeded re-submission as DUPLICATE and the seeded junk payload as MALFORMED.

Cross-check against raw chain data — no component from The Graph in the trust path — and then both sources against each other:

node dist/completeness-checker/cli.js verify --disclosure "$DISCLOSURE" \
  --source chain --rpc <your Sepolia archive RPC> --to-block "$BLOCK"

node dist/completeness-checker/cli.js verify --disclosure "$DISCLOSURE" \
  --source gateway \
  --url "https://gateway.thegraph.com/api/deployments/id/$DEPLOYMENT" \
  --api-key "$GRAPH_API_KEY" --block "$BLOCK" \
  --cross-check-chain --rpc <your Sepolia archive RPC> --to-block "$BLOCK"

The captured graph-attestation can be verified deeply — signature recovery under the current DisputeManager domain, signer resolved to a staked allocation — with the bond-replay CLI from the private-bond audit replay demonstration, which owns that step; this checker records attestations rather than re-implementing the verification.

The deep-audit path — rebuild everything from zero locally, including the dishonest-server fixtures that trigger GAP and CHAIN-BREAK — is the repository's REPRODUCE.md, Mode B.

Honest limits

Links