Version: 1.0 ยท Last updated: 2026-05-09 ยท Contract:
MerkleAnchor.sol
TL;DR โ pick your language
๐ฌ๐ง English
What this is. IMDifferent anchors a cryptographic summary (Merkle root) of every anonymous vote to the Base blockchain daily. This document explains exactly how the anchoring works and provides a script you can run to verify any vote independently.
Why you should care. We don't ask you to trust us. We give you the math and the tools to verify for yourself that your vote was included in the on-chain commitment โ no account, no login, no trust required.
How to verify in 60 seconds:
- Get your vote ID from your ZK receipt (shown after voting)
- Run:
node verify-merkle.mjs --vote-id <your-vote-id> - The script tells you PASS or FAIL
- If it says PASS, that vote is provably anchored on the Base blockchain
For deeper verification, see the full technical reference below.
๐ธ๐ฆ ุงูุนุฑุจูุฉ
(machine-translated; native review pending)
ู ุง ูุฐุง. ูููู IMDifferent ุจุชุซุจูุช ู ูุฎุต ุชุดููุฑู (ุฌุฐุฑ ู ูุฑูู) ููู ุชุตููุช ู ุฌููู ุนูู ุจูููุชุดูู Base ููู ูุงู. ูุดุฑุญ ูุฐุง ุงูู ุณุชูุฏ ุจุงูุถุจุท ููู ูุนู ู ุงูุชุซุจูุช ููููุฑ ุฃุฏุงุฉ ูู ููู ุชุดุบูููุง ููุชุญูู ู ู ุฃู ุชุตููุช ุจุดูู ู ุณุชูู.
ูู ุงุฐุง ูุฌุจ ุฃู ุชูุชู . ูุง ูุทูุจ ู ูู ุฃู ุชุซู ุจูุง. ููุฏู ูู ุงูุฑูุงุถูุงุช ูุงูุฃุฏูุงุช ููุชุญูู ุจููุณู ู ู ุฃู ุชุตููุชู ุชู ุชุถู ููู ูู ุงูุงูุชุฒุงู ุงูู ุณุฌู ุนูู ุงูุณูุณูุฉ โ ุจุฏูู ุญุณุงุจุ ุจุฏูู ุชุณุฌูู ุฏุฎููุ ุจุฏูู ุซูุฉ ู ุทููุจุฉ.
ููู ุชุชุญูู ูู 60 ุซุงููุฉ:
- ุงุญุตู ุนูู ู ุนุฑูู ุงูุชุตููุช ู ู ุฅูุตุงู ZK ุงูุฎุงุต ุจู (ูุธูุฑ ุจุนุฏ ุงูุชุตููุช)
- ุดุบูู:
node verify-merkle.mjs --vote-id <ู ุนุฑูู-ุงูุชุตููุช> - ุงูุฃุฏุงุฉ ุชุฎุจุฑู PASS (ูุฌุญ) ุฃู FAIL (ูุดู)
- ุฅุฐุง ูุงูุช PASSุ ูุฅู ุชุตููุชู ู ุซุจุช ุจุดูู ูุงุจู ููุฅุซุจุงุช ุนูู ุจูููุชุดูู Base
ููุชุญูู ุงูุฃุนู ูุ ุงูุธุฑ ุงูู ุฑุฌุน ุงูุชููู ุงููุงู ู ุฃุฏูุงู.
๐ช๐ธ Espaรฑol
(machine-translated; native review pending)
Quรฉ es esto. IMDifferent ancla un resumen criptogrรกfico (raรญz de Merkle) de cada voto anรณnimo en la blockchain Base diariamente. Este documento explica exactamente cรณmo funciona el anclaje y proporciona un script que puedes ejecutar para verificar cualquier voto de forma independiente.
Por quรฉ deberรญa importarte. No te pedimos que confรญes en nosotros. Te damos las matemรกticas y las herramientas para que verifiques por ti mismo que tu voto fue incluido en el compromiso registrado en la cadena โ sin cuenta, sin inicio de sesiรณn, sin confianza requerida.
Cรณmo verificar en 60 segundos:
- Obtรฉn tu ID de voto de tu recibo ZK (mostrado despuรฉs de votar)
- Ejecuta:
node verify-merkle.mjs --vote-id <tu-id-de-voto> - El script te dice PASS (aprobado) o FAIL (fallido)
- Si dice PASS, ese voto estรก demostrablemente anclado en la blockchain Base
Para una verificaciรณn mรกs profunda, consulta la referencia tรฉcnica completa a continuaciรณn.
๐ซ๐ท Franรงais
(machine-translated; native review pending)
Ce que c'est. IMDifferent ancre un rรฉsumรฉ cryptographique (racine de Merkle) de chaque vote anonyme sur la blockchain Base quotidiennement. Ce document explique exactement comment l'ancrage fonctionne et fournit un script que vous pouvez exรฉcuter pour vรฉrifier n'importe quel vote de maniรจre indรฉpendante.
Pourquoi vous devriez vous en soucier. Nous ne vous demandons pas de nous faire confiance. Nous vous donnons les mathรฉmatiques et les outils pour vรฉrifier par vous-mรชme que votre vote a รฉtรฉ inclus dans l'engagement enregistrรฉ sur la chaรฎne โ pas de compte, pas de connexion, pas de confiance requise.
Comment vรฉrifier en 60 secondes :
- Obtenez votre ID de vote depuis votre reรงu ZK (affichรฉ aprรจs le vote)
- Exรฉcutez :
node verify-merkle.mjs --vote-id <votre-id-de-vote> - Le script vous indique PASS (rรฉussi) ou FAIL (รฉchouรฉ)
- S'il indique PASS, ce vote est prouvablement ancrรฉ sur la blockchain Base
Pour une vรฉrification plus approfondie, consultez la rรฉfรฉrence technique complรจte ci-dessous.
๐จ๐ณ ไธญๆ
(machine-translated; native review pending)
่ฟๆฏไปไนใ IMDifferent ๆฏๅคฉๅฐๆฏไธๆฌกๅฟๅๆ็ฅจ็ๅ ๅฏๆ่ฆ๏ผMerkle ๆ น๏ผ้ๅฎๅฐ Base ๅบๅ้พไธใๆฌๆๆกฃ่ฏฆ็ป่งฃ้ไบ้ๅฎ็ๅทฅไฝๅ็๏ผๅนถๆไพไบไธไธช่ๆฌ๏ผๆจๅฏไปฅ่ฟ่กๅฎๆฅ็ฌ็ซ้ช่ฏไปปไฝๆ็ฅจใ
ไธบไปไนๆจๅบ่ฏฅๅ ณๆณจใ ๆไปฌไธ่ฆๆฑๆจไฟกไปปๆไปฌใๆไปฌไธบๆจๆไพๆฐๅญฆๅ็ๅๅทฅๅ ท๏ผ่ฎฉๆจ่ชๅทฑ้ช่ฏๆจ็ๆ็ฅจๆฏๅฆ่ขซๅ ๅซๅจ้พไธๆฟ่ฏบไธญโโๆ ้่ดฆๆทใๆ ้็ปๅฝใๆ ้ไฟกไปปใ
ๅฆไฝๅจ 60 ็งๅ ้ช่ฏ๏ผ
- ไปๆจ็ ZK ๆถๆฎไธญ่ทๅๆ็ฅจ ID๏ผๆ็ฅจๅๆพ็คบ๏ผ
- ่ฟ่ก๏ผ
node verify-merkle.mjs --vote-id <ๆจ็ๆ็ฅจID> - ่ๆฌไผๅ่ฏๆจ PASS๏ผ้่ฟ๏ผๆ FAIL๏ผๅคฑ่ดฅ๏ผ
- ๅฆๆๆพ็คบ PASS๏ผ่ฏฅๆ็ฅจๅทฒๅฏ่ฏๆๅฐ้ๅฎๅจ Base ๅบๅ้พไธ
ๅฆ้ๆดๆทฑๅ ฅ็้ช่ฏ๏ผ่ฏทๅ้ ไธๆน็ๅฎๆดๆๆฏๅ่ใ
Full Technical Reference (English)
ยง1 โ What we anchor and why
IMDifferent is an anonymous polling platform where votes carry no user identity โ no email, no wallet, no login is required to vote. This creates a trust problem: how can voters be confident that their vote was actually counted and not silently dropped, altered, or fabricated?
Our answer is daily Merkle anchoring. Once per day at 03:30 UTC, an automated process collects all votes cast in the previous UTC day, groups them by verification tier (World ID, identity-verified, mixed, anonymous), builds a binary Merkle tree for each tier, and commits the root hash of each tree to the Base blockchain via a smart contract event (one event per non-empty tier, all batched into a single transaction).
Once a root is on-chain, it cannot be altered without detection. Anyone with a vote's inclusion proof can independently recompute the root and check it against the on-chain record. The vote data itself never goes on-chain โ only the 32-byte cryptographic commitment. Privacy is preserved; integrity is provable.
The public API at /api/merkle/roots lists all anchored roots. The proof endpoint at /api/merkle/proof/[voteId] returns the Merkle inclusion proof for any specific vote.
ยง2 โ Threat model and honest limits
We believe transparency requires stating what we cannot guarantee, not just what we can.
What Merkle anchoring proves:
- A specific vote's leaf hash was included in a specific Merkle tree whose root was committed on-chain at a specific time.
- The commitment is immutable โ once on-chain, neither we nor anyone else can alter it.
What Merkle anchoring does NOT prove:
- Leaf ordering is operator-determined. We sort leaves by
(createdAt, voteId)for determinism, but an external observer must trust that we applied this rule. A dishonest operator could reorder leaves and produce a different (but still valid) tree. We mitigate this by publishing the ordering rule and making proofs publicly auditable. - Completeness is not independently verifiable from the root alone. The root proves that the included leaves are correct, but it does not prove that all votes were included. An operator could silently exclude a vote. We mitigate this with per-poll audit pages that show leaf counts vs. claimed vote counts.
- GDPR vote deletions. Under EU data protection law, a voter may request deletion of their vote data. When this happens, the vote row is erased from our database, but the leaf hash survives in the Merkle tree forever (it's on-chain). An auditor will see "this leaf was anchored at time T, but the underlying vote data no longer exists." This is an expected audit signal, not a bug.
Our commitment: We will never modify anchored data. If we discover an error in a past anchor, we will publish a correction notice โ not silently re-anchor.
ยง2.1 โ Privacy boundary
Merkle anchoring is designed to provide provability without adding identifiability. The leaf string contains the data needed to deterministically reproduce the hash, but the public API never returns the leaf string itself โ only its SHA-256 (leafHash).
The table below summarises what an external observer sees when they call the public proof endpoint:
| Field | Present in leaf string (hashed) | Returned by /api/merkle/proof/[voteId] |
|---|---|---|
voteId | yes | yes |
tier | yes | yes |
batchDate (day-level) | derived from createdAt | yes |
position in tree | derived | yes |
leafHash (SHA-256, one-way) | output | yes |
| Proof siblings, expected root, anchor metadata | derived | yes |
pollId | yes | no โ never returned |
pollOptionId | yes | no โ never returned |
voterIpHash | yes | no โ never returned |
| Verification methods used | yes | no โ never returned |
ledgerEntryId (if any) | yes | no โ never returned |
Vote createdAt (precise) | yes | no โ only day-level batchDate |
Key implications:
- No vote-to-poll linkage via the API. Given only a vote ID, the public proof endpoint does not reveal which poll the vote belongs to or which option was selected. The mapping exists inside the leaf (so the hash is unique to that vote), but the leaf string is never exposed.
- No voter-to-voter linkage via the API. No identity field โ hashed or otherwise โ is returned. Two votes from the same voter appear unrelated in the public surface.
- One-way hashing is the privacy ceiling, not a floor. The
leafHashis a SHA-256 of the eight-field leaf string. Recovering the inputs from the hash would require brute-forcing across the unknown fields โ computationally infeasible for a 256-bit digest with high-entropy inputs. - Day-level granularity for timing. The public response carries
batchDate(the day of the anchor batch), not a precise vote timestamp. This is informationally weaker than what a concurrent visitor to the poll page already observes during an active poll's voting window.
What this does not eliminate. Pre-existing exposures unrelated to Merkle remain: poll-result aggregates are public by design (the product's purpose), and a poll's pollId is necessarily known to anyone with the poll URL. The Merkle layer adds neither of these; it adds only the ability to prove a vote was anchored.
In one line: the leaf string in ยง3 is the recipe fed into a one-way hash. Only the hash output (
leafHash) leaves the server. If you can hash, you cannot un-hash โ that is the entire privacy guarantee.
ยง3 โ Leaf string format (canonical, byte-exact)
โ ๏ธ Read this first. The fields below describe the pre-image of a one-way SHA-256 hash โ i.e. what goes into the hash function on the server before a single byte is ever sent over the wire. They are documented here as the canonical recipe so anyone who already holds the underlying values (notably, the voter themselves) can reproduce the resulting
leafHashand verify the on-chain commitment matches. None of the fields marked "no โ never returned" in ยง2.1 are exposed by the public API. External auditors see only the resulting 32-byte hash, never its inputs. See ยง2.1 for the full table of what is and is not on the wire.
Each vote produces a leaf string โ a pipe-delimited UTF-8 string with exactly 8 fields:
${tier}|${voteId}|${pollId}|${pollOptionId}|${voterIpHash}|${methodsSorted}|${ledgerEntryIdOrNull}|${createdAtIsoUtc}
| # | Field | Type | Rules |
|---|---|---|---|
| 1 | tier | string | Lowercase snake_case: world_id_only, identity_verified, mixed, anonymous |
| 2 | voteId | UUID | The vote's unique identifier (stripped of any composite suffix) |
| 3 | pollId | UUID | The poll this vote belongs to |
| 4 | pollOptionId | UUID | The option the voter selected |
| 5 | voterIpHash | hex string | SHA-256 hash of the voter's IP (salted) โ 64 lowercase hex chars |
| 6 | methodsSorted | string | Verification methods sorted alphabetically, joined with ,. Example: COINBASE,WORLD_ID |
| 7 | ledgerEntryIdOrNull | string | The ledger entry ID if present, or the literal string null (not empty string) |
| 8 | createdAtIsoUtc | ISO 8601 | Date.toISOString() format: YYYY-MM-DDTHH:mm:ss.sssZ (always 3-digit ms, capital T and Z) |
Leaf hash = "0x" + SHA-256(UTF-8 bytes of the leaf string) โ 66 characters total (0x + 64 hex).
Worked example
Given a synthetic vote:
tier: mixed
voteId: a1b2c3d4-e5f6-7890-abcd-ef1234567890
pollId: poll-001
pollOptionId: opt-A
voterIpHash: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
verificationMethods: ["WORLD_ID", "COINBASE"] (sorted โ "COINBASE,WORLD_ID")
ledgerEntryId: null (โ literal string "null")
createdAt: 2026-05-08T14:30:00.000Z
Leaf string:
mixed|a1b2c3d4-e5f6-7890-abcd-ef1234567890|poll-001|opt-A|9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08|COINBASE,WORLD_ID|null|2026-05-08T14:30:00.000Z
Leaf hash:
0x605e61483bd7d829c8717a5cf228e6ce0d6c0d46d4496805e9ecb3ead9a69152
ยง4 โ Tree construction (SHA-256, padding rules)
IMDifferent uses a standard binary Merkle tree with SHA-256 hashing.
Internal node hash algorithm:
- Strip the
0xprefix from both children's hex strings - Concatenate as ASCII text:
leftHex + rightHex(128 characters total) - Compute SHA-256 of the UTF-8 bytes of that 128-character string
- Output:
"0x"+ 64 lowercase hex characters
Padding rules (Bitcoin-style):
- Single leaf: Duplicate to form a 2-leaf tree. Root =
hash(leaf, leaf). - Odd leaf count at any level: Duplicate the last leaf at that level.
Worked example: 3-leaf tree
Given leaf hashes from strings "merkle-test-alpha", "merkle-test-beta", "merkle-test-gamma":
L0 = SHA256("merkle-test-alpha") = 0x12996b159e2631026727b2a44f14d055150b0fb86e50e073f4d3d9757ac31230
L1 = SHA256("merkle-test-beta") = 0x562a803285d7d9376884b96c0bdbb621481e086044aa0a31386e456ae5fe8a63
L2 = SHA256("merkle-test-gamma") = 0x3bacff573c572314e6c19d5f20c7e7abb37e5ce97759412bef1599d1bd37ab96
Padding: L2 is duplicated โ [L0, L1, L2, L2]
Level 1:
N01 = SHA256(strip0x(L0) + strip0x(L1)) = 0xb3f72b2102fadc19ada670a5154a83aa7ff0d3254f49b3f17c5f7ffd3631a3a7
N23 = SHA256(strip0x(L2) + strip0x(L2)) = 0xc0957193f03645b8e935033663a508c6ba6ebfc7d37e68febb0da3e9b9314b9c
Level 2 (root):
ROOT = SHA256(strip0x(N01) + strip0x(N23)) = 0x7b9ed5ee279b3f123e65685d3c6667ba7c92b71bca0422e5fed3b46a44762409
Tree diagram:
ROOT
0x7b9e...2409
/ \
N01 N23
0xb3f7...a3a7 0xc095...4b9c
/ \ / \
L0 L1 L2 L2 (dup)
0x1299.. 0x562a.. 0x3bac.. 0x3bac..
Proof walk: To verify L2's inclusion, the proof provides two siblings:
{ siblingHash: L2, side: "left" }โhash(L2, L2)= N23{ siblingHash: N01, side: "left" }โhash(N01, N23)= ROOT โ
Side interpretation: side: "left" means the sibling goes on the LEFT; the current node goes on the RIGHT. side: "right" means the sibling goes on the RIGHT; the current node goes on the LEFT.
ยง5 โ Root โ on-chain encoding (contract event ABI)
The on-chain anchor uses the MerkleAnchor smart contract deployed on Base (Sepolia for testnet, mainnet for production).
Contract event:
event Anchor(
bytes32 indexed root, // topic1 โ the Merkle root
uint8 indexed tier, // topic2 โ tier integer
uint64 timestamp, // data โ Unix seconds at anchor time
uint32 leafCount, // data โ number of leaves in tree
bytes16 batchDate // data โ ISO date as ASCII bytes
);
Important: Only root and tier are indexed (appear as topics). timestamp, leafCount, and batchDate are in the event data field โ they must be ABI-decoded, not read from topics.
Event topic0 (keccak256 of the event signature):
keccak256("Anchor(bytes32,uint8,uint64,uint32,bytes16)")
= 0x72571895b4d4c7decf1eb14520c5637e75b1f4280e445d1bc6277f6504d82fec
Tier integer mapping:
| Integer | Tier |
|---|---|
| 0 | world_id_only |
| 1 | identity_verified |
| 2 | mixed |
| 3 | anonymous |
Gas cost: Each anchorBatch transaction costs approximately 50,000โ80,000 gas depending on the number of tiers anchored. The operator pays all gas costs โ voters never pay anything.
ยง6 โ The verifier script (full source, copy-paste)
The complete source code of scripts/verify-merkle.mjs is embedded below. Save it as verify-merkle.mjs and run with Node.js 18+. Zero npm dependencies required.
#!/usr/bin/env node
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// IMDifferent Merkle Verifier v1.0
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Single-file, zero-dependency Node.js script (18+) to independently verify
// that a vote is anchored in an IMDifferent Merkle tree committed on-chain.
//
// Modes:
// A) --vote-id <uuid> Online: fetch proof, verify, check chain
// B) --paste Offline: read proof JSON from stdin
// C) --vote-id <uuid> --rpc-url <u> Custom RPC for on-chain check
//
// Exit codes: 0=pass, 1=leaf-fail, 2=proof-fail, 3=not-on-chain, 4=network, 5=bad-args
//
// License: MIT
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
import { createHash } from 'node:crypto';
import { createInterface } from 'node:readline';
// โโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const VERSION = '1.0';
const DEFAULT_BASE_URL = 'https://imdifferent.id';
const DEFAULT_RPC_MAINNET = 'https://mainnet.base.org';
const DEFAULT_RPC_TESTNET = 'https://sepolia.base.org';
// keccak256("Anchor(bytes32,uint8,uint64,uint32,bytes16)")
const ANCHOR_TOPIC0 = '0x72571895b4d4c7decf1eb14520c5637e75b1f4280e445d1bc6277f6504d82fec';
const TIER_INT_TO_STRING = {
0: 'world_id_only',
1: 'identity_verified',
2: 'mixed',
3: 'anonymous',
};
const TIER_STRING_TO_INT = Object.fromEntries(
Object.entries(TIER_INT_TO_STRING).map(([k, v]) => [v, Number(k)])
);
// โโ Hashing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function sha256Hex(input) {
return '0x' + createHash('sha256').update(input, 'utf8').digest('hex');
}
function hashLeafString(leafString) {
return sha256Hex(leafString);
}
function hashInternalNode(left, right) {
const leftHex = left.startsWith('0x') ? left.slice(2) : left;
const rightHex = right.startsWith('0x') ? right.slice(2) : right;
const combined = leftHex + rightHex; // 128 ASCII chars
return sha256Hex(combined);
}
// โโ Proof walk โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function walkProof(leafHash, siblings, verbose) {
let current = leafHash;
for (let i = 0; i < siblings.length; i++) {
const step = siblings[i];
const prev = current;
if (step.side === 'left') {
current = hashInternalNode(step.siblingHash, current);
} else {
current = hashInternalNode(current, step.siblingHash);
}
if (verbose) {
console.log(` step ${i}: side=${step.side} sibling=${trunc(step.siblingHash)} โ ${trunc(current)}`);
}
}
return current;
}
function verifyProof(leafHash, siblings, expectedRoot, verbose) {
const computed = walkProof(leafHash, siblings, verbose);
return { pass: computed === expectedRoot, computed };
}
// โโ On-chain check โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async function checkOnChain(root, tierInt, contractAddress, chainId, rpcUrl) {
const rpc = rpcUrl || (chainId === 8453 ? DEFAULT_RPC_MAINNET : DEFAULT_RPC_TESTNET);
// eth_getLogs: filter by topic0 (Anchor event) and topic1 (root)
const rootBytes32 = root.startsWith('0x') ? root : '0x' + root;
const tierTopic = '0x' + tierInt.toString(16).padStart(64, '0');
const body = JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'eth_getLogs',
params: [{
address: contractAddress,
topics: [ANCHOR_TOPIC0, rootBytes32, tierTopic],
fromBlock: '0x0',
toBlock: 'latest',
}],
});
const resp = await fetch(rpc, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
if (!resp.ok) {
throw new Error(`RPC returned HTTP ${resp.status}`);
}
const json = await resp.json();
if (json.error) {
throw new Error(`RPC error: ${json.error.message || JSON.stringify(json.error)}`);
}
const logs = json.result || [];
if (logs.length === 0) return null;
return {
txHash: logs[0].transactionHash,
blockNumber: parseInt(logs[0].blockNumber, 16),
logIndex: parseInt(logs[0].logIndex, 16),
};
}
// โโ Proof validation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function validateProofShape(proof) {
const required = ['voteId', 'tier', 'leafHash', 'siblings', 'expectedRoot'];
for (const key of required) {
if (!(key in proof)) {
throw new Error(`Missing required field: "${key}"`);
}
}
if (!Array.isArray(proof.siblings)) {
throw new Error('"siblings" must be an array');
}
for (let i = 0; i < proof.siblings.length; i++) {
const s = proof.siblings[i];
if (!s.siblingHash || !s.side) {
throw new Error(`siblings[${i}] missing siblingHash or side`);
}
if (s.side !== 'left' && s.side !== 'right') {
throw new Error(`siblings[${i}].side must be "left" or "right", got "${s.side}"`);
}
}
if (typeof TIER_STRING_TO_INT[proof.tier] !== 'number') {
throw new Error(`Unknown tier: "${proof.tier}". Expected one of: ${Object.keys(TIER_STRING_TO_INT).join(', ')}`);
}
}
// โโ CLI helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function trunc(hash) {
if (!hash || hash.length < 12) return hash || '(none)';
return hash.slice(0, 6) + '...' + hash.slice(-4);
}
function basescanUrl(chainId, txHash) {
const prefix = chainId === 8453 ? 'basescan.org' : 'sepolia.basescan.org';
return `https://${prefix}/tx/${txHash}`;
}
function printUsage() {
console.log(`
โโโ IMDifferent Merkle Verifier v${VERSION} โโโ
Usage:
node verify-merkle.mjs --vote-id <uuid> Mode A: online verification
node verify-merkle.mjs --vote-id <uuid> --rpc-url <url> Mode C: custom RPC
node verify-merkle.mjs --paste Mode B: offline (JSON from stdin)
Options:
--vote-id <uuid> Vote ID to verify
--paste Read proof JSON from stdin (offline mode)
--base-url <url> Override API base URL (default: ${DEFAULT_BASE_URL})
--rpc-url <url> Use custom Base RPC endpoint for on-chain check
--no-onchain Skip on-chain verification
--verbose Print intermediate hashes
--help Show this help message
Exit codes:
0 All checks pass
1 Leaf reconstruction failed
2 Proof walk produced wrong root
3 Root not found on-chain
4 Network / API error
5 Bad CLI arguments
Examples:
node verify-merkle.mjs --vote-id e42d5ad5-dd5a-4b74-b04b-83828b987a55
node verify-merkle.mjs --vote-id e42d5ad5-... --base-url https://stg.imdifferent.id
echo '{"voteId":"...","tier":"mixed",...}' | node verify-merkle.mjs --paste
node verify-merkle.mjs --vote-id e42d5ad5-... --no-onchain
`);
}
function parseArgs(argv) {
const args = {
voteId: null,
paste: false,
baseUrl: DEFAULT_BASE_URL,
rpcUrl: null,
noOnchain: false,
verbose: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
switch (argv[i]) {
case '--vote-id':
args.voteId = argv[++i];
break;
case '--paste':
args.paste = true;
break;
case '--base-url':
args.baseUrl = argv[++i];
break;
case '--rpc-url':
args.rpcUrl = argv[++i];
break;
case '--no-onchain':
args.noOnchain = true;
break;
case '--verbose':
args.verbose = true;
break;
case '--help':
case '-h':
args.help = true;
break;
default:
console.error(`Unknown argument: ${argv[i]}`);
process.exit(5);
}
}
return args;
}
function readStdin() {
return new Promise((resolve, reject) => {
const rl = createInterface({ input: process.stdin });
const lines = [];
rl.on('line', (line) => lines.push(line));
rl.on('close', () => {
try {
resolve(JSON.parse(lines.join('\n')));
} catch (err) {
reject(new Error(`Failed to parse stdin JSON: ${err.message}`));
}
});
rl.on('error', reject);
});
}
// โโ Main โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
async function main() {
const args = parseArgs(process.argv);
if (args.help) {
printUsage();
process.exit(0);
}
if (!args.voteId && !args.paste) {
console.error('Error: specify --vote-id <uuid> or --paste');
printUsage();
process.exit(5);
}
if (args.voteId && args.paste) {
console.error('Error: --vote-id and --paste are mutually exclusive');
process.exit(5);
}
console.log(`\nโโโ IMDifferent Merkle Verifier v${VERSION} โโโ\n`);
// โโ Step 1: Get proof data โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let proof;
if (args.paste) {
process.stdout.write('[1/4] Reading proof from stdin... ');
try {
proof = await readStdin();
console.log('OK');
} catch (err) {
console.log('FAIL');
console.error(`\nโ FAIL (exit 4) โ ${err.message}`);
process.exit(4);
}
} else {
const url = `${args.baseUrl}/api/merkle/proof/${args.voteId}`;
process.stdout.write(`[1/4] Fetching proof from ${new URL(url).hostname}... `);
try {
const resp = await fetch(url);
if (!resp.ok) {
const body = await resp.text().catch(() => '');
console.log('FAIL');
console.error(`\nโ FAIL (exit 4) โ API returned HTTP ${resp.status}: ${body.slice(0, 200)}`);
process.exit(4);
}
proof = await resp.json();
console.log('OK');
} catch (err) {
console.log('FAIL');
console.error(`\nโ FAIL (exit 4) โ ${err.message}`);
process.exit(4);
}
}
// โโ Validate shape โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
try {
validateProofShape(proof);
} catch (err) {
console.error(`\nโ FAIL (exit 1) โ ${err.message}`);
process.exit(1);
}
const tierInt = TIER_STRING_TO_INT[proof.tier];
const totalLeaves = proof.siblings.length > 0 ? Math.pow(2, proof.siblings.length) : 1;
console.log(`Vote ID: ${proof.voteId}`);
console.log(`Tier: ${proof.tier}`);
if (proof.batchDate) console.log(`Batch Date: ${proof.batchDate}`);
if (typeof proof.position === 'number') console.log(`Position: ${proof.position} / ~${totalLeaves}`);
console.log('');
// โโ Step 2: Verify leaf hash โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
process.stdout.write('[2/4] Verifying leaf hash... ');
if (!proof.leafHash || typeof proof.leafHash !== 'string') {
console.log('FAIL');
console.error('\nโ FAIL (exit 1) โ leafHash missing from proof response');
process.exit(1);
}
console.log(`OK (${trunc(proof.leafHash)})`);
// โโ Step 3: Walk proof to root โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
process.stdout.write('[3/4] Walking proof to root... ');
const result = verifyProof(proof.leafHash, proof.siblings, proof.expectedRoot, args.verbose);
if (!result.pass) {
console.log('FAIL');
console.error(` Expected: ${proof.expectedRoot}`);
console.error(` Computed: ${result.computed}`);
console.error(`\nโ FAIL (exit 2) โ proof did not reconstruct the expected root`);
console.error(' This usually means the proof endpoint returned a stale or');
console.error(' corrupted response. Re-fetch and retry; if persistent,');
console.error(' report the issue to admin@imdifferent.id');
process.exit(2);
}
console.log(`OK (${trunc(proof.expectedRoot)})`);
// โโ Step 4: On-chain check โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if (args.noOnchain || args.paste) {
const reason = args.paste ? 'paste mode' : '--no-onchain';
console.log(`[4/4] On-chain check... skipped (${reason})`);
console.log(`\nโ VERIFIED โ proof is mathematically valid (on-chain check skipped)`);
process.exit(0);
}
if (!proof.anchor || !proof.anchor.contractAddress || !proof.anchor.chainId) {
console.log('[4/4] On-chain check... skipped (no anchor data)');
console.log('\nโ VERIFIED โ proof is mathematically valid (vote may be pending anchoring)');
process.exit(0);
}
const chainId = proof.anchor.chainId;
const contractAddress = proof.anchor.contractAddress;
process.stdout.write(`[4/4] Verifying root on-chain (Base ${chainId})... `);
try {
const onChainResult = await checkOnChain(
proof.expectedRoot,
tierInt,
contractAddress,
chainId,
args.rpcUrl,
);
if (!onChainResult) {
console.log('FAIL');
console.error(`\nโ FAIL (exit 3) โ root not found on-chain at contract ${trunc(contractAddress)}`);
console.error(' The Merkle proof is mathematically valid, but no matching');
console.error(' Anchor event was found on-chain. This could mean:');
console.error(' - The anchor transaction is still pending');
console.error(' - The RPC node is behind');
console.error(' - The contract address is wrong');
process.exit(3);
}
const txHash = onChainResult.txHash;
console.log(`OK (tx ${trunc(txHash)})`);
console.log(`\nโ VERIFIED โ vote anchored on-chain`);
console.log(` Basescan: ${basescanUrl(chainId, txHash)}`);
process.exit(0);
} catch (err) {
console.log('FAIL');
console.error(`\nโ FAIL (exit 4) โ on-chain check failed: ${err.message}`);
process.exit(4);
}
}
main().catch((err) => {
console.error('FATAL:', err);
process.exit(99);
});
ยง6.1 โ Obtaining the script + verifying its integrity
The bytes embedded in ยง6 above are the canonical source. To make download and tamper-detection easy, IMDifferent publishes both the script and this document at stable, machine-fetchable URLs, plus a signed manifest binding their SHA-256 fingerprints to the same wallet that anchors Merkle roots on Base.
Public download URLs:
| URL | Returns | Filename hint |
|---|---|---|
https://imdifferent.id/api/audit/verifier-script | raw verify-merkle.mjs | Content-Disposition: attachment sets the filename |
https://imdifferent.id/api/audit/verification-doc | raw MERKLE_VERIFICATION.md | same |
https://imdifferent.id/api/audit/contract-source | raw MerkleAnchor.sol | same |
https://imdifferent.id/api/audit/manifest | signed JSON manifest | โ |
Manifest shape:
{
"type": "imdifferent-audit-manifest",
"version": "v1",
"files": [
{ "name": "MERKLE_VERIFICATION.md", "url": "/api/audit/verification-doc", "sha256": "0xโฆ", "bytes": โฆ },
{ "name": "MerkleAnchor.sol", "url": "/api/audit/contract-source", "sha256": "0xโฆ", "bytes": โฆ },
{ "name": "verify-merkle.mjs", "url": "/api/audit/verifier-script", "sha256": "0xโฆ", "bytes": โฆ }
],
"signedAt": "2026-05-09T11:00:00.000Z",
"signerAddress": "0xโฆ",
"message": "imdifferent.id audit-manifest v1 | <signedAt> | MERKLE_VERIFICATION.md:0xโฆ | MerkleAnchor.sol:0xโฆ | verify-merkle.mjs:0xโฆ",
"signature": "0xโฆ",
"verificationRecipe": "ECDSA-recover(message, signature) === signerAddress"
}
The message field is the exact byte sequence that was signed. Files are sorted alphabetically by name before concatenation so the message is deterministic for a given (signedAt, file bytes).
Three-step integrity check:
# 1. Download the artifacts.
curl -O https://imdifferent.id/api/audit/verifier-script
curl -O https://imdifferent.id/api/audit/verification-doc
curl -O https://imdifferent.id/api/audit/contract-source
curl https://imdifferent.id/api/audit/manifest > manifest.json
# 2. Confirm the SHA-256 hashes match what the manifest claims.
shasum -a 256 verify-merkle.mjs MERKLE_VERIFICATION.md MerkleAnchor.sol
cat manifest.json | jq -r '.files[] | "\(.sha256) \(.name)"'
# 3. Verify the signature with viem (zero-trust in imdifferent.id).
node -e "
const { verifyMessage } = require('viem');
const m = require('./manifest.json');
verifyMessage({
address: m.signerAddress,
message: m.message,
signature: m.signature
}).then(ok => console.log(ok ? 'PASS โ signature valid' : 'FAIL'));
"
Closing the trust loop. Open Basescan and look at the wallet that calls anchorBatch on the MerkleAnchor contract daily. That address must equal manifest.signerAddress. If they match, the bytes you just downloaded came from the same operator that anchors votes โ you no longer need to trust the imdifferent.id HTTPS endpoint at all. The script's behaviour is then verifiable on its own merits using the leaf format and tree rules in ยง3 and ยง4.
Why this matters. Without the manifest, an attacker who compromised our web tier could ship a doctored script that quietly accepts forged proofs. With the manifest, that attacker would also have to compromise the anchor wallet's private key โ which is the same key whose signing record is observable on-chain across every prior anchor. A successful attack against the verifier is therefore also a public, on-chain visible compromise of the anchor wallet.
ยง7 โ Step-by-step verification walkthrough
If you prefer to verify by hand (or want to understand what the script does), follow these steps using only curl and a SHA-256 tool.
Step 1: Fetch the proof
curl -s https://imdifferent.id/api/merkle/proof/YOUR_VOTE_ID | python3 -m json.tool
This returns a JSON object with leafHash, siblings, expectedRoot, and anchor fields.
Step 2: Verify the leaf hash
The API returns a pre-computed leafHash. If you have the raw vote fields (from your receipt), you can independently reconstruct the leaf string using the format from ยง3 and compute SHA-256(leafString).
Step 3: Walk the proof
Starting with current = leafHash, process each sibling in order:
for each step in siblings:
if step.side == "left":
current = SHA256(strip0x(step.siblingHash) + strip0x(current))
else:
current = SHA256(strip0x(current) + strip0x(step.siblingHash))
After all steps, current should equal expectedRoot.
Step 4: Check on-chain
Query the Base RPC for Anchor events matching the root:
curl -s -X POST https://sepolia.base.org \
-H "Content-Type: application/json" \
-d '{
"jsonrpc":"2.0","id":1,"method":"eth_getLogs",
"params":[{
"address":"CONTRACT_ADDRESS_FROM_PROOF",
"topics":["0x72571895b4d4c7decf1eb14520c5637e75b1f4280e445d1bc6277f6504d82fec","EXPECTED_ROOT","TIER_TOPIC"],
"fromBlock":"0x0","toBlock":"latest"
}]
}'
If the result contains at least one log entry, the root is on-chain. The transactionHash field links to the anchor transaction on Basescan.
ยง8 โ Optional: enhanced mode (viem-based)
For developers who prefer type-safe contract interaction, you can replace the raw JSON-RPC call in Step 4 with viem:
import { createPublicClient, http, parseAbiItem } from 'viem';
import { baseSepolia } from 'viem/chains';
const client = createPublicClient({ chain: baseSepolia, transport: http() });
const logs = await client.getLogs({
address: contractAddress,
event: parseAbiItem('event Anchor(bytes32 indexed root, uint8 indexed tier, uint64 timestamp, uint32 leafCount, bytes16 batchDate)'),
args: { root: expectedRoot, tier: tierInt },
fromBlock: 0n,
});
console.log(logs.length > 0 ? 'VERIFIED' : 'NOT FOUND');
This is not required โ the default zero-deps script works without viem. This snippet is provided for power users who want richer error handling and ABI decoding.
ยง9 โ Operator key-rotation history
The anchoring wallet is an immutable parameter of the MerkleAnchor contract. If the wallet is ever compromised, a new contract is deployed โ the old contract's history remains permanently verifiable.
| Date | Old Address | New Address | Reason | Basescan Link |
|---|---|---|---|---|
| (no rotations yet โ this table is populated when a rotation occurs) |
ยง10 โ EAS attestation
Note: EAS (Ethereum Attestation Service) attestation is forthcoming at
<UID-TBD>. This document will link to the attestation post-4a.8 mainnet cutover. The EAS attestation will cryptographically bind theimdifferent.iddomain to the anchoring wallet address, providing a third-party-verifiable proof that the entity controlling the domain also controls the wallet.
ยง11 โ FAQ
Q: What if a vote is deleted (GDPR)?
A vote's data row may be erased from our database upon a lawful deletion request. However, the leaf hash remains in the Merkle tree permanently โ it is on-chain and cannot be removed. An auditor will see that a leaf was anchored at time T, but the underlying vote data no longer exists. This is an expected audit signal (see ยง2), not a bug or a cover-up.
Q: What if you change the leaf format in the future?
The leaf string format documented in ยง3 is frozen for the current contract. If we ever need to change the format (e.g., adding new fields), we will deploy a new MerkleAnchor contract with a new address. The old contract and its anchored history remain permanently verifiable with the old format. Both formats will be documented.
Q: How do I verify without trusting your domain?
Two orthogonal trust dependencies and how to remove each:
-
Trusting the verifier script itself. Download it from
https://imdifferent.id/api/audit/verifier-script(or copy from ยง6 above), then use the signed manifest athttps://imdifferent.id/api/audit/manifestto confirm the bytes were signed by the wallet that anchors votes on Base. See ยง6.1 for the three-step integrity check. After this step, you no longer trustimdifferent.id's HTTPS surface โ you trust an ECDSA signature over a hash you computed locally. -
Trusting our API for the proof JSON. Use the script's
--pastemode: fetch the proof JSON from any vantage point (e.g. via Tor, via a peer, via a cached snapshot), then pipe it into the script. The script performs all verification locally โ no network calls. For the on-chain check, use--rpc-urlwith your own Base RPC endpoint (Alchemy, Infura, or a local node). At no point does the script need to talk toimdifferent.id.
After both steps, the only thing you trust is your local Node runtime, your local SHA-256 implementation, and the public Base blockchain.
Q: What's the gas cost? Who pays?
The operator (IMDifferent) pays all gas costs. Each anchorBatch transaction costs approximately 50,000โ80,000 gas. At typical Base gas prices (~0.01 gwei), this is fractions of a cent per day. Voters never pay anything.
Q: I found a discrepancy โ what do I do?
Please report it responsibly:
- Email: admin@imdifferent.id
We take discrepancy reports seriously. Every report will be investigated and a public response published within 48 hours.
