Anchor Worker
The AdPriva Anchor Worker is the service that batches cryptographic engagement proofs and anchors Merkle roots to Horizen mainnet — making every engagement permanently verifiable on-chain.
What It Does
At a high level, the Anchor Worker runs a continuous two-stage pipeline every 5 minutes:
- Batch — Query the database for unprocessed (
pending) engagement proofs, group them into a Merkle tree, and compute the Merkle root. - Anchor — Recompute the root from the batch’s proof ids, then submit it to the
Anchor.solsmart contract on Horizen mainnet, emitting anAnchoredevent that creates a permanent, public record.
View batches anchored before 2026-09-28 had a step between the two: a Groth16 zk-SNARK was submitted to zkVerify and the root was anchored after zkVerify accepted it. That step was retired on 2026-10-16. See Legacy: zero-knowledge step.
This pipeline runs as a single Node.js worker process on DigitalOcean App Platform, replacing the previous three-Lambda AWS architecture.
Architecture
Go Server
│
▼ INSERT proofs
MySQL (proofs, proof_batches, anchor_jobs tables)
│
▼ Poll every 5 minutes
DO Anchor Worker (Node.js)
│
├─► Batch: Poseidon Merkle tree, root recomputed from the proof ids
│
└─► Horizen Mainnet (Anchor.sol)
└─► anchorBatch(merkleRoot)
└─► Anchored event emitted (permanent on-chain record)
│
▼ UPDATE anchor_jobs (status='completed', result={txHash})
MySQL
Why a Single Worker?
The previous AWS Lambda approach required three separate services (batch creator, zkVerify submitter, Horizen anchor) connected by SQS queues. This added operational complexity and latency.
The Anchor Worker replaced all three with a single stateful process:
| Before (AWS Lambda) | After (DO Anchor Worker) |
|---|---|
| 3 Lambda functions | 1 Node.js process |
| 3 SQS queues + 3 DLQs | No queues — DB polling |
| CloudWatch + Secrets Manager | DO App Platform env vars |
| ~$3.56/month (10K proofs) | ~$6/month (DO plan) |
The simplification reduces failure surface area, makes logs centralized, and eliminates SQS propagation delays.
Proof Lifecycle
Every proof follows this path through the Anchor Worker:
Engagement event → Go server captures a view or click, creates HMAC-signed proof, inserts row into
proofstable withzkverify_status = 'pending'.Batch → Worker queries unbatched proofs from the
proofstable, organizes them into a Poseidon Merkle tree, computes the root, and creates aproof_batchesrow + ananchor_jobsrow withjob_type = 'zkverify_submit'. The job type keeps its historical name.Skip zkVerify → Worker claims the
zkverify_submitjob and recomputes the Merkle root from the job’s proof ids. It stops if the result differs from the stored root. WithPROOF_BATCHES_SKIP_ZKVERIFY=true, which is on in production, it generates no Groth16 proof and makes no Kurier call: it sets the batch’szkverify_statustoNOT_APPLICABLEand creates ananchor_submitjob.On-chain anchoring → Worker claims the
anchor_submitjob and callsAnchor.sol.anchorBatch(merkleRoot)on Horizen mainnet, with the on-chain noteAdPriva Proof Batch (no zkVerify). The contract emitsAnchored(merkleRoot)and stores nothing, so an anchor is cheap: 28,784 gas measured on a production batch on 2026-10-16.Status update → Worker marks anchor job as
status = 'completed'with thetxHashin the result field. Proofs in the batch are marked anchored, and theirzkverify_statusmirrors the batch:not_applicablefor a batch anchored without zkVerify.
Legacy path: zkVerify
The flag is off unless it is exactly true. With it off, step 3 is the zkVerify path that view batches used until 2026-09-28: the worker downloads the circuit artifacts (proof_verification_v2.0.0.wasm, *.zkey) from Cloudflare R2, generates a Groth16 zk-SNARK over the Merkle tree, and submits it to zkVerify through the Kurier API. zkVerify checks it (~25 seconds, ~$0.05/batch) and returns an attestationId, and the worker then creates the anchor_submit job. Proofs in such a batch are marked zkverify_status = 'verified'.
Public Verification
Anyone can verify a proof by:
- Looking up the proof ID in the AdPriva Explorer.
- Tracing the Merkle root to the on-chain
Anchoredevent on Horizen mainnet. - Recomputing the root from the proof’s own Merkle receipt —
GET /public/proofs/{proof_id}returnsleaf,leaf_index,merkle_pathandmerkle_path_indices, which fold back to the anchored root offline. Steps 1 and 2 alone only show that some root was anchored; this step is what shows the proof is in it. See the API Reference for the fold, or use the browser verifier atexplorer.adpriva.com/verify/{proof_id}. - For a batch anchored before 2026-09-28, cross-referencing the zkVerify
attestationIdfor the legacy zero-knowledge attestation.
A limit worth knowing: batches are currently small, so a receipt is exact but the anonymity set it places a proof in is correspondingly small. Current batch sizes are public via proof_count on GET /public/anchors/recent.
The Anchor contract (Anchor.sol) is deployed on Horizen mainnet (Chain ID: 26514, RPC: https://horizen.calderachain.xyz/http).
Legacy: Zero-Knowledge Proof Details
For view batches anchored before 2026-09-28, AdPriva used Groth16 zk-SNARKs with a custom Circom circuit (proof_verification_v2.0.0):
- Circuit inputs: proof leaf hashes, Merkle path, Merkle root, timestamp
- Output: single bit — proof is valid and belongs to the committed Merkle root
- Proving key: stored in Cloudflare R2 (
circuits/v2.0.0/) - Verification: performed by zkVerify’s on-chain verifier
This means that even the Anchor Worker never sees raw user data — it only processes cryptographic hashes produced by the Go server.
Deployment
The Anchor Worker runs on DigitalOcean App Platform as a background worker (no inbound HTTP). Infrastructure is managed via OpenTofu in the infrastructure/ repository.
Key environment variables:
| Variable | Description |
|---|---|
DATABASE_URL | MySQL connection string (private VPC endpoint) |
WORKER_START_DATE | Process only proofs after this date (migration cutoff) |
PROOF_BATCHES_SKIP_ZKVERIFY | true anchors proof batches directly on Horizen, with no zero-knowledge proof and no zkVerify call. On in production since 2026-10-16. Off unless set to exactly true |
HORIZEN_ANCHOR_SUBMITTER_KEY | Horizen wallet private key for anchoring |
HORIZEN_ANCHOR_CONTRACT_ADDRESS | Anchor.sol address on Horizen mainnet |
KURIER_API_KEY | Legacy: zkVerify Kurier API key, used only by the zkVerify path |
R2_PUBLIC_ENDPOINT | Legacy: Cloudflare R2 endpoint for circuit artifacts, used only by the zkVerify path |
CIRCUIT_VERSION | Legacy: circuit version for the zkVerify path (e.g. v2.0.0) |
Deployment is currently manual. The GitHub Actions workflow in AdPriva/proofs (.github/workflows/deploy-anchor-worker.yml) still runs on push to main, but its deploy step does not succeed until its DigitalOcean credentials are renewed. Until then the worker image is built, pushed to the DigitalOcean registry and deployed by hand, as described in the proofs README.