Inspiration

Five people in Amritsar were defrauded by the same operator. Their complaints became one case only because an investigator, by hand, noticed the modus operandi matched. That merge is the moment the fraud became actionable — and it happened late, by accident, and only because a human went looking.

The sequence that would actually help is report → discover you are not alone → act together. Reporting inverts it. The first person to report bears the entire cost: the embarrassment of having been fooled, their identity and the amount handed to a portal, and in return a complaint number. Nobody tells them whether anyone else reported the same payment handle, because nobody knows. You report into silence and hope.

The obvious fix is a public scam blacklist, and every version of it fails the same two ways. A public list of accused handles is a defamation and extortion surface — one anonymous submission and a small merchant's UPI ID is poisoned with no recourse. And a list that requires accounts to prevent that abuse becomes a registry of who reported whom, which is exactly the exposure people were avoiding when they stayed silent.

The insight that made Chorus buildable: nobody needs to browse a scam database. A victim already knows the handle that took their money. A person about to send money already knows the handle they are about to pay. In both cases the query is already in their hand. So the register does not have to be readable — it only has to be queryable by someone who already holds the answer's key, and unreadable to everyone else, including the people running it.

What it does

Chorus is a public register of scam reports where the register itself is unreadable, and the only thing anyone can do with it is confirm a handle they already have.

Two actions, deliberately asymmetric:

Report is a transaction. You enter the payment handle that took your money. Your device derives a commitment to that handle and a nullifier unique to you-and-that-handle, proves in zero knowledge that both were derived correctly from a handle you hold and a secret only you hold, and publishes only those two 32-byte values. The commitment's counter goes up by one. The nullifier is recorded so you cannot inflate that counter by reporting the same handle twice — even though nothing on chain knows who you are or how many handles you have reported.

Check is free, instant, and involves no transaction at all. You enter the handle you are about to pay. Your device derives the same commitment locally — same handle, same commitment, always — and reads the counter off the public ledger. No account, no wallet, no request to any server that could log the query, no on-chain footprint. Check is a local hash and a public read.

The threshold. Each commitment carries a count and a match flag. At three independent reports — three distinct nullifiers — the commitment is flagged as matched. The flag releases no name, no amount, no narrative. It releases exactly one bit, and only to people who can already derive that commitment: you are not the only one. Three strangers who have never communicated and cannot identify each other simultaneously learn they were hit by the same operator.

The threshold is what makes the first report cost nothing, because it discloses nothing until it stops being the first.

Public, permanently, to anyone 32-byte commitments · a count for each · 32-byte nullifiers · a match flag · the sealed threshold
Never public, at any point, to anyone The payment handle · the amount lost · the reporter's identity, wallet or secret · which nullifiers belong to the same person · how many handles one person reported · what happened

An observer with a full copy of the chain and unlimited compute learns that some handle received three reports. They do not learn which handle, from whom, or for how much.

How we built it

The contract (contract/src/chorus.compact, Compact language_version 0.23) is one circuit that writes and two that do not.

export ledger reportCount:      Map<Bytes<32>, Uint<64>>;
export ledger seenNullifiers:   Set<Bytes<32>>;
export ledger matched:          Set<Bytes<32>>;
export sealed ledger threshold: Uint<64>;   // written once, in the constructor

report(handleBytes) takes the reporter's secret as a witness — supplied by the local runtime, never transmitted, structurally incapable of reaching the chain. Exactly two values are wrapped in disclose():

const commitment = disclose(commitmentOf(handleBytes));
const nullifier  = disclose(nullifierOf(handleBytes, sk));

Everything else in the circuit is private by construction, and the compiler refuses to build a contract that leaks one by accident. The privacy guarantee is a compile-time property, not a paragraph in a privacy policy. Both derivations are domain-separated (chorus:handle:v1, chorus:nullifier:v1) — the separator is visible in the compiled zkir as the immediate 63686F7275733A68616E646C653A7631.

sealed on the threshold means it cannot be changed after deployment by anyone, including the deployer. A trust property of the deployment rather than a promise from its operator.

There is deliberately no check circuit. commitmentOf and nullifierOf touch no ledger state, so the compiler emits them as pure circuits — plain functions in the generated TypeScript API, evaluable locally with no proof, no transaction, no network request. Deriving through the contract's own circuit rather than reimplementing the hash in TypeScript also means the client and the chain cannot drift apart. Turning Check into a circuit would have destroyed the property that makes it worth having.

The workspace is four packages: contract/ (the Compact source, witnesses, normaliser, 11 tests), api/, cli/ (the interactive driver plus a local reporter agent), and ui/ (Vite + React — Report, Check, and a raw ledger panel).

The split between api/index.ts and api/read.ts is load-bearing. read.ts imports nothing that needs a wallet, a proof server, or private state. The browser bundles read.ts and physically cannot submit anything with it — which is what makes "checking involves no transaction" a structural fact about the build rather than a claim.

REPORT   browser ──▶ reporter agent (127.0.0.1) ──▶ proof server ──▶ testnet
         handle normalised + SHA-256'd in the browser;
         only the 32 hashed bytes cross to the agent;
         only commitment + nullifier reach the chain

CHECK    browser ──▶ public indexer
         the agent is not involved and need not be running

The agent exists because reporting needs a funded wallet and the ability to sign; in production that is Lace's job. A CHORUS_NETWORK=local mode runs the agent against the compiled circuits in process, so a machine without a funded wallet can still exercise the whole UI — it renders a SIMULATED MODE banner and reports simulated: true on /api/status, because output from that mode must never be mistaken for testnet.

The demo video is built, not filmed. Playwright drives the real UI, which drives the real compiled circuits, and screenshots whatever actually happened; a director page composites those shots with motion graphics and captions; Playwright records the result. No screen recorder, no editor. Callout positions are measured off the live DOM, and the commitment and nullifier that animate into place are the ones the circuits actually produced for the handle in the footage — typing plausible-looking hex would have been easier and would have been a lie.

Challenges we ran into

The dust wallet sync nearly ate the project. Before anything touches the chain, three sub-wallets must sync. On PreProd (~2.16M blocks) the unshielded wallet finished in seconds, shielded in ~8 minutes at ~3,100 items/s — and dust crawled at ~60–104/s, or 3.5 to 6 hours. It is not skippable (generateDust() opens with waitForSyncedState()), there is no persistence (the CLI passes in-memory txHistoryStorage, so every restart rescans from zero), and at the default log level it prints nothing and looks hung the entire time. Sync scales with chain history, so we moved to Preview (~472K blocks), which does in minutes what PreProd needs hours for.

The faucet is captcha-gated, and that is what blocked deployment. Preview's faucet sat at HTTP 503 for hours — and the testkit health-checks it at startup, so it aborts before reaching any prompt even when the chain and indexer are perfectly healthy. When it recovered, funding still required solving a Cloudflare Turnstile by hand: no CI, no scripting, no automation. The Chorus contract is not yet deployed to testnet for this reason, and the README says so in the first screenful rather than burying it. Report, duplicate rejection and the threshold flip are all demonstrated against the compiled circuits in local simulation mode, with an explicit table of what that does and does not prove.

Windows is not a supported platform, and it lies about it. compact --version on Windows resolves to the built-in compact.exe — NTFS folder compression — and cheerfully prints a compression report. It is a false positive that looks like success. The toolchain publishes only darwin and linux-musl builds; everything runs in WSL2. Docker Desktop's WSL integration then fights a native Docker CE install for /var/run/docker.sock and fails with an opaque failed to read component versions.

Docs drift from artifacts, and the drift bites. compact 0.5.1 ships compactc 0.31.1 — separate version numbers. The install page says proof server 8.1.0; the scaffold actually launches 8.0.3. The docs say Node 22+; the template pins >=24.11.1. Every version in our README was observed on the machine, not copied from a page.

persistentHash needs an explicit type parameter — the vector type and arity are both required, and the error does not make that obvious.

Normalisation is a silent failure mode. If two clients normalise handles differently, two victims of the same scammer derive different commitments, never match, and Chorus does nothing at all — with no error anywhere. So the rules are deliberately conservative (NFKC, strip whitespace, lowercase, nothing else) and tested directly. Dots and plus-addressing are not stripped, because a.b@upi and ab@upi can be different accounts and collapsing them would land one merchant's reports on another's commitment.

Accomplishments that we're proud of

A test that proves the privacy claim rather than asserting it. After three reports of the same handle, neither the hashed handle, nor the raw handle string, nor any reporter's secret appears anywhere in public ledger state, and every public value is exactly 32 bytes. 11/11 tests pass; the full pipeline (compact → typecheck → lint → build → test) exits 0.

Check needs no server, structurally. Not "we promise not to log queries" — the read path cannot write, and the browser has no code that could.

Independent on-chain verification. For the environment milestone we deployed the unmodified official example to PreProd and confirmed it by querying the indexer's GraphQL API directly rather than trusting the CLI's own output — contract 065e3428…a944, tx b00ae97c…edab, SUCCESS at block 2,173,800.

The limits are named, not hidden. The commitment space is grindable — a payment handle is not high-entropy, and an attacker who enumerates plausible UPI IDs can discover which handles carry reports. What Chorus protects perfectly is the reporter; what it protects imperfectly is the target. Reports are unverified: the circuit proves the derivation was correct, not that a scam occurred. And one bit is not a case — knowing three people were hit recovers nobody's money. A judge who finds an unnamed hole discounts everything else, so we named them.

What we learned

Compile-time disclosure changes how you design. Once every leak has to be an explicit disclose(), the interesting question stops being "what do we store" and becomes "what is the smallest thing that could possibly be published." The answer here turned out to be two hashes and a counter.

The best engineering decision was choosing what not to make a circuit. Check is the more valuable half of the product, and it works precisely because it is not a transaction. Recognising that the pure-circuit path gave us a free, traceless, serverless read was worth more than any optimisation to the write path.

Privacy tech is only as good as the boring string handling underneath it. A perfect ZK proof over an inconsistently normalised handle produces two commitments that will never meet, and the system fails by doing nothing at all — the hardest failure mode to notice.

Demo operations are a real engineering constraint on this stack. Wallet sync time, faucet availability and captcha gating are not footnotes; they decide whether a live demo is possible. Plan for them first, not last.

What's next for Chorus

Deploy to Preview testnet. Everything else is built and green; this is one captcha and one funded wallet away. The threshold flip then gets demonstrated on chain, verifiable against the public indexer by anyone.

Close the grindability gap. A distributed pepper or threshold decryption held by parties who must cooperate to grind, plus rate-limited derivation — so the target is protected as well as the reporter.

Raise the cost of a report rather than adjudicating it. A stake, an attestation, or a rate-limiting nullifier per reporter per period. The design should never claim to establish truth; it establishes independent coincidence, which is a weaker and more honest claim.

Retire the reporter agent. Move signing into the Lace wallet path so the browser talks to the chain directly and no local process ever holds a key.

Change the domain string, get a different product. The mechanism is a primitive: hold a claim in escrow until enough people independently make the same one, without any of them revealing the claim or themselves. The same deployed contract with a different commitment domain is a harassment register for a workplace where the first complainant is the one who gets fired, a landlord register where tenants cannot afford to be identified, a bad-client register among freelancers. Scams are the case you can feel in thirty seconds. They are not the boundary of the idea.

Built With

Share this project:

Updates

Submission history