返回

独立验证指南

面向审计员和记者的技术参考,帮助他们独立验证 Base 上任何已锚定的投票。

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:

  1. Get your vote ID from your ZK receipt (shown after voting)
  2. Run: node verify-merkle.mjs --vote-id <your-vote-id>
  3. The script tells you PASS or FAIL
  4. 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 ثانية:

  1. احصل على معرّف التصويت من إيصال ZK الخاص بك (يظهر بعد التصويت)
  2. شغّل: node verify-merkle.mjs --vote-id <معرّف-التصويت>
  3. الأداة تخبرك PASS (نجح) أو FAIL (فشل)
  4. إذا قالت 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:

  1. Obtén tu ID de voto de tu recibo ZK (mostrado después de votar)
  2. Ejecuta: node verify-merkle.mjs --vote-id <tu-id-de-voto>
  3. El script te dice PASS (aprobado) o FAIL (fallido)
  4. 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 :

  1. Obtenez votre ID de vote depuis votre reçu ZK (affiché après le vote)
  2. Exécutez : node verify-merkle.mjs --vote-id <votre-id-de-vote>
  3. Le script vous indique PASS (réussi) ou FAIL (échoué)
  4. 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 秒内验证:

  1. 从您的 ZK 收据中获取投票 ID(投票后显示)
  2. 运行:node verify-merkle.mjs --vote-id <您的投票ID>
  3. 脚本会告诉您 PASS(通过)或 FAIL(失败)
  4. 如果显示 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:

What Merkle anchoring does NOT prove:

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:

FieldPresent in leaf string (hashed)Returned by /api/merkle/proof/[voteId]
voteIdyesyes
tieryesyes
batchDate (day-level)derived from createdAtyes
position in treederivedyes
leafHash (SHA-256, one-way)outputyes
Proof siblings, expected root, anchor metadataderivedyes
pollIdyesno — never returned
pollOptionIdyesno — never returned
voterIpHashyesno — never returned
Verification methods usedyesno — never returned
ledgerEntryId (if any)yesno — never returned
Vote createdAt (precise)yesno — only day-level batchDate

Key implications:

  1. 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.
  2. 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.
  3. One-way hashing is the privacy ceiling, not a floor. The leafHash is 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.
  4. 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 leafHash and 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}
#FieldTypeRules
1tierstringLowercase snake_case: world_id_only, identity_verified, mixed, anonymous
2voteIdUUIDThe vote's unique identifier (stripped of any composite suffix)
3pollIdUUIDThe poll this vote belongs to
4pollOptionIdUUIDThe option the voter selected
5voterIpHashhex stringSHA-256 hash of the voter's IP (salted) — 64 lowercase hex chars
6methodsSortedstringVerification methods sorted alphabetically, joined with ,. Example: COINBASE,WORLD_ID
7ledgerEntryIdOrNullstringThe ledger entry ID if present, or the literal string null (not empty string)
8createdAtIsoUtcISO 8601Date.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:

  1. Strip the 0x prefix from both children's hex strings
  2. Concatenate as ASCII text: leftHex + rightHex (128 characters total)
  3. Compute SHA-256 of the UTF-8 bytes of that 128-character string
  4. Output: "0x" + 64 lowercase hex characters

Padding rules (Bitcoin-style):

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:

  1. { siblingHash: L2, side: "left" }hash(L2, L2) = N23
  2. { 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:

IntegerTier
0world_id_only
1identity_verified
2mixed
3anonymous

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:

URLReturnsFilename hint
https://imdifferent.id/api/audit/verifier-scriptraw verify-merkle.mjsContent-Disposition: attachment sets the filename
https://imdifferent.id/api/audit/verification-docraw MERKLE_VERIFICATION.mdsame
https://imdifferent.id/api/audit/contract-sourceraw MerkleAnchor.solsame
https://imdifferent.id/api/audit/manifestsigned 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.

DateOld AddressNew AddressReasonBasescan 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 the imdifferent.id domain 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:

  1. 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 at https://imdifferent.id/api/audit/manifest to 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 trust imdifferent.id's HTTPS surface — you trust an ECDSA signature over a hash you computed locally.

  2. Trusting our API for the proof JSON. Use the script's --paste mode: 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-url with your own Base RPC endpoint (Alchemy, Infura, or a local node). At no point does the script need to talk to imdifferent.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:

We take discrepancy reports seriously. Every report will be investigated and a public response published within 48 hours.

← 返回锚定浏览器