返回

公共 API 参考

本页目录

Auditor- and integrator-facing reference for the public transparency / Merkle / audit endpoints exposed at https://imdifferent.id. This document is the source of truth; the in-app /transparency/api page renders from it.

Scope: 9 read-only public endpoints (no API key required). Internal scheduled jobs and admin endpoints are out of scope. Voting submission and poll CRUD endpoints are out of scope.

Last updated: 2026-05-18.


Conventions

AspectRule
Base URLhttps://imdifferent.id
AuthNone. All endpoints in this document are publicly accessible.
CORSAll endpoints set Access-Control-Allow-Origin: *.
CachingEach endpoint sets its own Cache-Control. Stable data (anchors, manifests) caches for 5 min – 1 day; volatile data (pending status) is no-store.
ErrorsJSON body of shape { "error": "..." } on 4xx/5xx (some endpoints add extra context fields — see per-endpoint sections).
IDsAll UUIDs are lowercase, hyphenated, 36 chars. Hashes use lowercase hex with 0x prefix.
DatesAll timestamps are ISO-8601 UTC (2026-05-14T02:34:14.000Z). Batch dates are date-only (2026-05-13).
Tier enumworld_id_only | identity_verified | mixed | anonymous. Returned in lowercase snake_case in API responses. The Anchor Browser also uses the synthetic value all for coalesced NO_VOTES rows.
Status enum (batch)ANCHORED (on-chain confirmed) | NO_VOTES (anchoring ran, zero leaves) | PENDING (computed, awaiting tx) | FAILED (tx reverted, retried). Public read endpoints surface only ANCHORED and NO_VOTES.
Dual-era IDsAny endpoint that takes a voteId parameter accepts BOTH a ballot_id (Phase 1+, since 2026-05-13) AND a legacy vote_id (pre-Phase-1). The backend resolves automatically. See Dual-era lookup.
Always-array shapeSince Phase 2 (2026-05-14 02:34 UTC), proof responses always return leaves: [...] as an array, with cardinality being the array length. Single-select ballots have cardinality: 1 (1-element array); multi-select ballots have cardinality: N. There is no special-cased "single leaf" branch for clients.

Endpoint index

#MethodPathPurpose
1GET/api/audit/manifestSigned manifest binding verifier+doc+contract bytes to the anchor wallet
2GET/api/audit/verifier-scriptRaw verify-merkle.mjs (download)
3GET/api/audit/verification-docRaw MERKLE_VERIFICATION.md (download)
4GET/api/audit/contract-sourceRaw MerkleAnchor.sol (download)
5GET/api/merkle/proof/[voteId]Inclusion proof for a vote / ballot (always-array)
6GET/api/merkle/poll/[pollId]Per-poll audit: anchored vs claimed tally per option
7GET/api/merkle/rootsAnchor browser feed: all anchored roots + NO_VOTES days
8POST GET/api/merkle/verifyFull receipt verification: hash check + Merkle inclusion
9POST/api/receipts/verifyReceipt-only check (no Merkle inclusion)

GET /api/audit/manifest

Returns a signed manifest that binds the SHA-256 of the verifier script, the verification doc, and the on-chain contract source to the same wallet that anchors Merkle roots on Base. Auditors verify the signature with viem's verifyMessage against signerAddress, then independently SHA the downloaded files (endpoints 2–4) and compare against the sha256 field for each.

ParamRequiredNotes
(none)No query / body.

Example request

curl -s https://imdifferent.id/api/audit/manifest

Example response — 200 OK

{
  "type": "imdifferent-audit-manifest",
  "version": "v1",
  "files": [
    {
      "name": "verify-merkle.mjs",
      "url": "/api/audit/verifier-script",
      "sha256": "0xe00dab316b8af8a332180566c685faa3c2f5d54b890febe21fcbe6ee8108d453",
      "bytes": 22385
    },
    {
      "name": "MERKLE_VERIFICATION.md",
      "url": "/api/audit/verification-doc",
      "sha256": "0xf7fae72e1bc4618b...",
      "bytes": 54814
    },
    {
      "name": "MerkleAnchor.sol",
      "url": "/api/audit/contract-source",
      "sha256": "0xca863221e996dd67...",
      "bytes": 2407
    }
  ],
  "signedAt": "2026-05-14T03:12:33.193Z",
  "signerAddress": "0x6321cC751D6B41A5D1256019F2AaadCA28dC9321",
  "message": "imdifferent.id audit-manifest v1 | 2026-05-14T03:12:33.193Z | MERKLE_VERIFICATION.md:0xf7fae72e... | MerkleAnchor.sol:0xca863221... | verify-merkle.mjs:0xe00dab31...",
  "signature": "0x1b33d47c9969bde342d8f6e9...",
  "verificationRecipe": "ECDSA-recover(message, signature) === signerAddress (use viem's verifyMessage or ethers' verifyMessage)"
}

Errors

StatusBodyMeaning
503{ error: "Manifest signing unavailable", reason: "...", files: [...] }MERKLE_ANCHOR_PRIVATE_KEY is missing or malformed in the deploy. Auditors can still download files from files[].url, but cannot verify signatures.
500{ error: "Manifest unavailable" }Filesystem or unexpected error.

Caching

Cache-Control: public, max-age=300, s-maxage=86400, stale-while-revalidate=86400. Re-signed at every cold-start, so deployed bytes ↔ manifest bytes stay in lockstep.


GET /api/audit/verifier-script

Returns the canonical bytes of scripts/verify-merkle.mjs as a downloadable .mjs file. Pair with endpoint 1 (manifest) to verify the bytes were signed by the anchor wallet.

ParamRequiredNotes
(none)No query / body.

Example request

curl -s -o verify-merkle.mjs https://imdifferent.id/api/audit/verifier-script
sha256sum verify-merkle.mjs    # compare against manifest.files[name=verify-merkle.mjs].sha256

Response — 200 OK

  • Content-Type: text/javascript; charset=utf-8
  • Content-Disposition: attachment; filename="verify-merkle.mjs"
  • Body: raw script bytes (currently ~22 KB).

Errors

StatusBodyMeaning
500{ error: "Verifier script unavailable" }Filesystem error.

GET /api/audit/verification-doc

Returns the canonical bytes of docs/MERKLE_VERIFICATION.md as a downloadable .md file. Same shape as endpoint 2 with Content-Type: text/markdown.

curl -s -o MERKLE_VERIFICATION.md https://imdifferent.id/api/audit/verification-doc
sha256sum MERKLE_VERIFICATION.md

GET /api/audit/contract-source

Returns the canonical bytes of contracts/MerkleAnchor.sol. Same shape as endpoint 2 with Content-Type: text/plain.

curl -s -o MerkleAnchor.sol https://imdifferent.id/api/audit/contract-source
sha256sum MerkleAnchor.sol

GET /api/merkle/proof/[voteId]

Returns a Merkle inclusion proof for a single ballot. The response always uses the array shape (since Phase 2): cardinality: N and leaves: [...] with N entries.

Path params

ParamRequiredNotes
voteIdyesEither a ballot_id (Phase 1+) or a legacy vote_id (pre-Phase-1). Backend resolves automatically — see Dual-era lookup.

Example request

curl -s https://imdifferent.id/api/merkle/proof/20968f36-7f38-44cb-9331-0e04cf2dd37c

Example response — 200 OK (multi-select, cardinality 3)

{
  "voteId": "20968f36-7f38-44cb-9331-0e04cf2dd37c",
  "tier": "mixed",
  "batchDate": "2026-05-13",
  "expectedRoot": "0x5818...8a19",
  "anchor": {
    "txHash": "0xb5e9f5417fb49935bffcc6fa0d1bb516c4f090c6a47cd7da3882c50ad4c737d4",
    "blockNumber": "45968355",
    "chainId": 8453,
    "contractAddress": "0x569e1ca29a8879d1394664ddfa3dad93ea9cc953"
  },
  "cardinality": 3,
  "leaves": [
    {
      "voteId": "20968f36-7f38-44cb-9331-0e04cf2dd37c_<optionId-1>",
      "pollOptionId": "<optionId-1>",
      "position": 14,
      "leafHash": "0xabc...",
      "siblings": [{ "position": "right", "hash": "0xdef..." }, ...]
    },
    { ... }, { ... }
  ]
}

Other response shapes

StatusBodyMeaning
202{ voteId, status: "pending_anchor", expectedAnchorAt }Vote exists but its batch hasn't been anchored yet. Anchoring runs daily at 03:30 UTC.
200{ voteId, status: "predates_anchoring", voteCreatedAt, cutoffDate }Vote was cast before Merkle anchoring began (pre-MERKLE_ANCHOR_CUTOFF_DATE).
404{ voteId, status: "not_found" }No matching ballot or vote row.
500{ error: "cardinality_mismatch", expected, got, voteId }Server-side integrity check failed: the number of returned leaves does not match the ballot's selection count. Surfaces honestly rather than returning a partial proof. Operator monitoring will fire on this.

Caching

Anchored: Cache-Control: public, max-age=300, s-maxage=300, stale-while-revalidate=600. All other states: no-store.

Rate limit

Shared audit-verify budget: 4 req / 60s per IP, shared across endpoints 5, 7, 8, and 9. See docs/RATE_LIMITS.md §25 for the rationale.


GET /api/merkle/poll/[pollId]

Per-poll audit: returns anchored vs claimed tally per option for a sealed poll. Used by the /transparency/poll/[pollId] UI. Designed to expose aggregate integrity (does the on-chain count of leaves per option match the database's claimed voteCount?) without leaking individual ballot IDs.

Path params

ParamRequiredNotes
pollIdyesUUID of a poll. Works for both sealed and open polls; response shape varies by status (see below).

Example request

curl -s https://imdifferent.id/api/merkle/poll/c1a55140-81a7-4e81-8ce3-4e56d1b249c8

Example response — 200 OK (sealed poll, status: available)

{
  "status": "available",
  "pollId": "c1a55140-81a7-4e81-8ce3-4e56d1b249c8",
  "sealedAt": "2026-05-13T22:00:00.000Z",
  "batches": [
    {
      "batchId": "...",
      "batchDate": "2026-05-13",
      "tier": "MIXED",
      "leafCount": 6,
      "root": "0x5818...8a19",
      "txHash": "0xb5e9f5417f...",
      "chainId": 8453,
      "contractAddress": "0x569e1ca29a8879d1394664ddfa3dad93ea9cc953",
      "blockNumber": "45968355",
      "anchoredAt": "2026-05-14T02:34:14.000Z",
      "basescanUrl": "https://basescan.org/tx/0xb5e9f5417f..."
    }
  ],
  "totals": {
    "anchoredVotes": 6,
    "claimedTotalVotes": 6,
    "anchoredTallyByOption": [
      { "optionId": "...", "optionText": "Caring",         "count": 2 },
      { "optionId": "...", "optionText": "Social Content", "count": 2 },
      { "optionId": "...", "optionText": "Breeding",       "count": 1 },
      { "optionId": "...", "optionText": "Friendship",     "count": 1 }
    ],
    "claimedTallyByOption": [ /* same shape, mirrors voteCount per option */ ],
    "tallyMatches": true
  }
}

Other status values

statusWhenBody shape
open_no_sealPoll exists but is not sealed yet{ status, pollId, sealedAt: null, message }
no_votesSealed poll received zero votes{ status, pollId, sealedAt, message }
predates_anchoringAll votes were cast before Merkle anchoring began{ status, pollId, sealedAt, cutoffDate, message }
pending_anchoringSealed poll still has unanchored batches{ status, pollId, sealedAt, progress: { anchoredBatches, totalBatches, anchoredVotes, totalVotes }, pendingBatches: [...] }

Errors

StatusBodyMeaning
404{ error: "Poll not found" }No poll with that ID.
429{ error: "Audit endpoint rate limit (4 req / 120s per IP). Next request in <ttl>s.", retryAfterSeconds }Too many distinct audit URLs from the same IP within the 120s window. See Rate limit below.
500{ error: "Internal server error" }Unexpected.

Privacy note

This endpoint deliberately does not return per-ballot IDs or per-voter information. The strongest assertion it can make about an individual ballot is that it was included in some daily Merkle root for the poll's tier — which is what /api/merkle/proof/[voteId] exposes when the voter (or an auditor with the receipt code) supplies the ID directly.

Rate limit

4 handler invocations / 120s per IP, across all polls. Keyed by IP only (not per-poll), so cache-bust loops over distinct poll IDs cap at the IP level rather than starting a fresh window per poll.

Two patterns:

  • Same-URL refresh (re-load one audit page): bounded by the CDN cache, not by this rate limit. The 60s s-maxage (see Caching) means the handler runs at most once per 60s per URL — well under the 4-per-120s cap. Refreshing one audit URL in a tight loop is always 200, never 429.
  • Cross-URL bursts (open many distinct audit URLs from the same IP in succession): first 4 unique URLs return 200; the 5th onwards returns 429 (also CDN-cached for 60s for the URL it was issued on). At t=120 the per-IP rate-limit window expires and the cycle restarts.

Caching

All responses (200 and 429): public, s-maxage=60 (no stale-while-revalidate).

SWR is deliberately omitted: for cross-URL bursts it would keep a stale 200 served on a URL that should have started returning 429, breaking the rate-limit defence. The audit endpoint is opened once per page-load, never polled, so the synchronous origin call once per 60s is invisible to real users. Net freshness is also better — status transitions (open→sealed, pending→available, new vote arrives) propagate within 60s instead of up to 6–15 min that a per-status SWR window would have allowed.


GET /api/merkle/roots

Anchor Browser data feed. Returns ANCHORED batches and NO_VOTES days (the latter coalesced when filtering all tiers, so a quiet day shows as a single row instead of four).

Query params

ParamRequiredDefaultNotes
limitno50Integer 1..200. Cap on the returned roots array length.
tiernoallOne of world_id_only | identity_verified | mixed | anonymous | all. When all, NO_VOTES days for which every tier had zero leaves are coalesced into one row with tier: "all" and isCoalesced: true.

Example request

curl -s 'https://imdifferent.id/api/merkle/roots?limit=20&tier=mixed'

Example response — 200 OK

{
  "anchorWalletAddress": "0x6321cC751D6B41A5D1256019F2AaadCA28dC9321",
  "anchorContractAddress": "0x569e1ca29a8879d1394664ddfa3dad93ea9cc953",
  "contractSourceUrl": "https://basescan.org/address/0x569e1ca29a8879d1394664ddfa3dad93ea9cc953#code",
  "chain": "base",
  "chainId": 8453,
  "explorerBaseUrl": "https://basescan.org/tx/",
  "easAttestation": "0x...",
  "easAttestationUrl": "https://base.easscan.org/attestation/view/0x...",
  "roots": [
    {
      "batchDate": "2026-05-13",
      "tier": "mixed",
      "leafCount": 41,
      "root": "0x5818...8a19",
      "anchoredAt": "2026-05-14T02:34:14.000Z",
      "txHash": "0xb5e9f5417f...",
      "blockNumber": "45968355",
      "signingKeyVersion": 1,
      "hmac": "0x...",
      "status": "ANCHORED"
    },
    {
      "batchDate": "2026-05-12",
      "tier": "all",
      "leafCount": 0,
      "root": "",
      "anchoredAt": "2026-05-13T03:30:00.000Z",
      "txHash": "",
      "blockNumber": "0",
      "signingKeyVersion": 0,
      "hmac": "",
      "status": "NO_VOTES",
      "isCoalesced": true
    }
  ],
  "generatedAt": "2026-05-14T03:13:00.000Z"
}

Errors

StatusBodyMeaning
400{ error: "Invalid limit. Must be 1..200." }limit out of range or non-numeric.
400{ error: "Invalid tier parameter. Must be one of: ..." }Unknown tier value.
500{ error: "Internal server error" }Unexpected.

Caching

revalidate = 300 (5 min). Anchors update at most once per day, so a 5-minute stale window is generous.

Pending fields

The response carries _anchorBasename_pending, _contractDeployedAt_pending, _lastProofOfLife_pending, and (when applicable) _easAttestation_pending. These are placeholders for fields that will be populated in later phases (Basename registration, monthly proof-of-life publication, etc.). Treat them as informational; don't rely on the underscore-prefixed keys staying around once the real fields land.

Rate limit

Shared audit-verify budget — see endpoint 5's rate limit note.


POST /api/merkle/verify (also GET ?code=…)

End-to-end receipt verification: validates the receipt hash, looks up the matching ballot, AND confirms the Merkle inclusion proof passes against the on-chain root. Used by the /transparency/verify UI. The single-call equivalent of (endpoint 9 + endpoint 5).

POST body

FieldRequiredTypeNotes
voteIdyesstringBallot ID (Phase 1+) or legacy vote ID.
hashyesstringThe 64-char hex hash from the receipt code.
ledgerEntryIdnostringRequired for token-gated polls; omit otherwise.

GET query

ParamRequiredNotes
codeyesFull receipt code as voteId:hash or voteId:hash:ledgerEntryId. The route splits on : and returns 400 if there are fewer than 2 or more than 3 parts.

Example request — POST

curl -s -X POST https://imdifferent.id/api/merkle/verify \
  -H 'Content-Type: application/json' \
  -d '{"voteId":"20968f36-7f38-44cb-9331-0e04cf2dd37c","hash":"abc..."}'

Example request — GET

curl -s 'https://imdifferent.id/api/merkle/verify?code=20968f36-7f38-44cb-9331-0e04cf2dd37c:abc...:ledgerXYZ'

Example response — 200 OK (anchored, multi-select)

{
  "success": true,
  "receiptValid": true,
  "merkle": {
    "anchored": true,
    "tier": "mixed",
    "batchDate": "2026-05-13",
    "txHash": "0xb5e9f5417f...",
    "txUrl": "https://basescan.org/tx/0xb5e9f5417f...",
    "blockNumber": "45968355",
    "verifiedAt": "2026-05-14T06:30:00.000Z",
    "cardinality": 3,
    "selections": [
      { "pollOptionId": "<optionId-1>", "leafHash": "0xabc...", "anchored": true },
      { "pollOptionId": "<optionId-2>", "leafHash": "0xdef...", "anchored": true },
      { "pollOptionId": "<optionId-3>", "leafHash": "0xfff...", "anchored": true }
    ]
  }
}

Other response shapes

BodyMeaning
{ success: false, receiptValid: false, error: "Receipt hash mismatch" }Hash does not match. The vote ID exists but the receipt was tampered, mistyped, or copied from a different deployment.
{ success: true, receiptValid: true, merkle: { anchored: false, expectedAnchorAt } }Receipt valid, but the batch hasn't been anchored yet.
{ success: true, receiptValid: true, merkle: { anchored: false, status: "no_leaf_yet" } }Receipt valid, but no Merkle leaf has been generated for this vote yet (anchoring hasn't run).
{ success: true, receiptValid: true, merkle: { anchored: false, status: "predates_anchoring", cutoffDate } }Receipt valid, but the vote was cast before Merkle anchoring began.

Errors

StatusBodyMeaning
400{ success: false, error: "Please provide a receipt code and hash to verify." }Missing voteId or hash (POST) or empty ?code= (GET).
400{ success: false, error: "Receipt code must be in the format voteId:hash or voteId:hash:ledgerEntryId." }GET only — ?code= had < 2 or > 3 colon-separated parts.
404{ success: false, error: "No matching vote record found." }Neither ballot nor legacy vote lookup found a match.
500{ success: false, error: "cardinality_mismatch", expected, got }Server-side integrity check failed (see endpoint 5).
500{ success: false, error: "Internal verification error" }A leaf failed Merkle proof verification — should never happen for honestly anchored data.
500{ success: false, error: "Internal server error" }Unexpected.

Rate limit

Shared audit-verify budget — see endpoint 5's rate limit note.


POST /api/receipts/verify

Receipt-only verification: validates the receipt hash without performing the Merkle inclusion check. Used by the /[lang]/receipts page to give voters an immediate "your receipt is real" answer before the slower Merkle round-trip. Prefer endpoint 8 if you want the full audit chain in one call.

POST body

FieldRequiredTypeNotes
voteIdyesstringBallot ID (Phase 1+) or legacy vote ID.
hashyesstring64-char hex receipt hash.
ledgerEntryIdnostringRequired for token-gated polls; omit otherwise.

Example request

curl -s -X POST https://imdifferent.id/api/receipts/verify \
  -H 'Content-Type: application/json' \
  -d '{"voteId":"20968f36-7f38-44cb-9331-0e04cf2dd37c","hash":"abc..."}'

Example response — 200 OK (success)

{
  "success": true,
  "data": {
    "voteId": "20968f36-7f38-44cb-9331-0e04cf2dd37c",
    "pollTitle": "Why do people bond with cats?",
    "pollLanguage": "en",
    "pollTranslations": [ /* localized titles */ ],
    "hashAlgorithm": "SHA-256 Verified"
  }
}

Other response shapes

BodyMeaning
{ success: false, error: "No matching vote record found. Please verify the receipt code is correct." }Neither ballot nor legacy vote lookup found a match.
{ success: false, error: "Verification failed. The receipt does not match our records." }The hash does not match either the new (with ledgerEntryId) or legacy (without ledgerEntryId) recipe.

Errors

StatusBodyMeaning
400{ success: false, error: "Please provide a receipt code and hash to verify." }Missing voteId or hash.
500{ success: false, error: "Something went wrong. Please try again." }Unexpected.

Rate limit

Shared audit-verify budget — see endpoint 5's rate limit note.


Receipt hash recipe

The hash component of a receipt code is computed by the server when the vote is recorded, then handed back to the voter as the second segment of voteId:hash[:ledgerEntryId].

hash = SHA-256(`${internalId}-${pollId}-${voterIpHash}-${ledgerEntryId || 'null'}`)
FieldMeaning
internalIdThe ballot's unique identifier (Phase 1+ format) or, for pre-Phase-1 receipts, the legacy vote identifier with its trailing _<optionId> suffix stripped (if present).
pollIdThe poll's UUID, as returned by the public poll endpoints.
voterIpHashThe voter's IP-hash field — HMAC-SHA-256 of the raw IP under a server-side key that rotates on a recurring schedule and is never exposed by any endpoint. Forgery resistance comes from this field, not from the recipe's secrecy.
ledgerEntryIdPre-allocated UUID for token-gated burn (one per ballot). For non-token-gated polls, the literal string 'null' is concatenated instead — the recipe is shape-stable.

Backward-compatible legacy recipe (pre-§B.3 receipts):

hash = SHA-256(`${internalId}-${pollId}-${voterIpHash}`)

The verify endpoints accept either recipe — the server tries both and matches whichever passes. Note: pollOptionId is intentionally NOT part of the hash. One ballot, one receipt, even when N selections sit underneath.


Dual-era lookup

Any endpoint that takes a voteId accepts BOTH:

  1. A ballot_id (UUID) — every ballot cast since the Phase 1 cutover at 2026-05-13 21:15 UTC has one. This is the canonical receipt identifier going forward (single-select and multi-select alike).
  2. A legacy vote_id (UUID) — every vote cast before Phase 1 has one, and was never associated with a Phase 1 ballot.

Resolution behaviour (used by endpoints 5, 8, 9):

  1. The backend first tries the value as a Phase 1+ ballot identifier. If found → it is used directly as internalId.
  2. Otherwise the backend tries it as a legacy vote identifier — including via prefix match, since legacy vote identifiers may carry a trailing _<optionId> suffix that must be stripped before comparison.
  3. If neither path matches → 404 not_found.

Why the prefix fallback exists: the legacy identifier was constructed as ${ballotId}_${optionId} for single-select Phase 1 too, so a Phase 1 ballot ID is also the prefix of the corresponding legacy vote ID. The prefix match catches the case where someone hands you the wrong half (the full vote ID instead of the parent ballot ID).


Tier classification (for context)

The daily Merkle anchoring process classifies each vote into exactly one tier based on the verification methods present on its ballot:

TierRule
WORLD_ID_ONLYVerified solely with World ID (Iris biometrics).
IDENTITY_VERIFIEDVerified via Coinbase or Human Passport (ECDSA signature).
MIXEDMultiple verification methods OR other supported integrations.
ANONYMOUSNo identity verification — rate-limited / fingerprint-only.

Each tier produces a separate daily Merkle batch; each batch produces one root, one transaction, one EAS attestation. The Anchor Browser (endpoint 7) returns one row per (date, tier) pair, except where coalescing applies.


Worked examples

A. Verify your own multi-select ballot end-to-end

RECEIPT="20968f36-7f38-44cb-9331-0e04cf2dd37c:abc123def456..."

curl -s "https://imdifferent.id/api/merkle/verify?code=${RECEIPT}" | jq

You should see success: true, receiptValid: true, merkle.anchored: true, merkle.cardinality: 3, and three entries in merkle.selections[].

B. Re-verify the same ballot with the standalone script

# Download the verifier and the manifest in parallel
curl -s -o verify.mjs https://imdifferent.id/api/audit/verifier-script
curl -s    https://imdifferent.id/api/audit/manifest > manifest.json

# Confirm the bytes match what the manifest says
echo "$(sha256sum verify.mjs | cut -d' ' -f1)" \
  | grep -i "$(jq -r '.files[] | select(.name=="verify-merkle.mjs") | .sha256[2:]' manifest.json)" \
  && echo OK

# Run the verifier (uses /api/merkle/proof/[voteId] under the hood)
node verify.mjs --vote-id=20968f36-7f38-44cb-9331-0e04cf2dd37c \
                --base-url=https://imdifferent.id

C. Audit a sealed poll's tally

curl -s https://imdifferent.id/api/merkle/poll/c1a55140-81a7-4e81-8ce3-4e56d1b249c8 | jq '.totals'

Look for tallyMatches: true. If false, every option's claimedCount vs anchoredCount is shown so you can identify which option drifted.

D. Pull the last 7 days of anchors

curl -s 'https://imdifferent.id/api/merkle/roots?limit=200' | jq '.roots | map(select(.batchDate >= "2026-05-08"))'

Voter count vs. selection count

Added 2026-05-17.

Endpoint 6 (/api/merkle/poll/[pollId]) distinguishes two numbers that historical responses called "votes" interchangeably:

ConceptDefinition
Voter count (= ballot count)Number of distinct ballots cast. One ballot per voter intent, regardless of how many options that ballot picked. Surfaced by the public poll endpoints as the poll's totalVotes field.
Selection countTotal number of option-counter increments. For single-select polls this equals voter count; for multi-select polls it is >= voter count. Equal to the sum of each option's voteCount.

The /api/merkle/poll/[pollId] totals block now returns BOTH naming conventions:

FieldMeaningStatus
anchoredSelectionsAnchored Merkle leaves (one leaf = one selection since Phase 2).New canonical name.
claimedSelectionCountSum of each option's voteCount — what the per-option counters claim.New canonical name.
anchoredVotesEqual to anchoredSelections.Deprecated alias — equal value, name will be removed after a deprecation window of at least one quarter.
claimedTotalVotesEqual to claimedSelectionCount.Deprecated alias — see above.

Integrators reading claimedTotalVotes / anchoredVotes continue to work unchanged. New integrators should read the canonical names.

For sealed-poll JSON snapshots (returned alongside poll metadata), snapshots written on or after 2026-05-17 carry an explicit selectionCount field. Older snapshots omit it and the read path computes it on the fly from options[].voteCount. Both shapes resolve to the same number — the new field is a forward migration so we can drop the recompute once legacy seals age out.


Change log

DateChange
2026-05-18Receipt-hash recipe gains an explicit threat-model line on voterIpHash (HMAC under a rotating server-side key). Internal implementation references throughout abstracted to observable behaviour for the public surface. No endpoint contract changes.
2026-05-17/api/merkle/poll/[pollId] now returns anchoredSelections + claimedSelectionCount alongside the deprecated anchoredVotes + claimedTotalVotes aliases. New sealed-poll snapshots carry an explicit selectionCount field. See Voter count vs. selection count above.
2026-05-14Initial publication. Phase 2 (per-selection anchoring + always-array proof shape) live as of 02:34 UTC; verifier v1.3 (narrow eth_getLogs window) live as of 03:12 UTC.
← 返回锚定浏览器