Send silently.
A zero-knowledge payment rail for USDC on Arc. Hushh severs the on-chain
link between who paid and who was paid, using a shielded note pool of Poseidon commitments
and Groth16 proofs of Merkle membership, while presenting the user with nothing more
complicated than sending a message to an @username.
A privacy protocol that overstates its guarantees is more dangerous than one that does not exist, because users calibrate real-world behaviour against its claims. Every property asserted here is stated with the assumption it rests on. Section 10 enumerates, without hedging, what Hushh does not protect. Where a number is an estimate rather than a measurement, it is labelled as such.
Every transfer on a public blockchain permanently publishes an edge:
sender → recipient. For payroll, donations, remittances and vendor payments,
that edge is not incidental metadata. It is the sensitive information. Hushh removes it.
Public chains are transparent by construction. The property that makes settlement independently verifiable also makes every economic relationship globally readable and trivially clusterable. Transparency is not a bug to be patched; it is the mechanism by which the ledger is trusted at all. The question is therefore not how to make the chain opaque, but how to publish enough for verification and nothing more.
The cost of publishing more than that is concrete and falls differently across use cases:
| Use case | What the chain discloses, permanently and to everyone |
|---|---|
| Payroll | Each contributor's exact compensation, payment cadence, and affiliation with the paying entity, visible to competitors, counterparties, and the contributor's own peers. Salary confidentiality, a near-universal norm off-chain, is unachievable on-chain. |
| Donations | A permanent, enumerable list of supporters of a political, legal, or humanitarian cause, in jurisdictions where such a list may later be used against them. Unlike a leaked donor list, this one cannot be retracted. |
| Remittances | Recipients in a single corridor become a targetable cohort with known balances and predictable arrival times. The threat here is not analytical but physical. |
| Commerce | Supplier identities, order volumes, margins, and payment terms, disclosed to any counterparty before a negotiation begins. |
| Personal | Rent, medical payments, legal retainers, and support to family members, all attached to a long-lived address that is frequently linked to a real identity through a single exchange deposit. |
The common structure is that the relationship, not the amount, is what leaks first and hurts most. An observer who learns that address A pays address B every fortnight has learned an employment relationship regardless of whether the amount is legible.
Unlinkability that a non-cryptographer can operate correctly on the first attempt, denominated in the stablecoin people actually hold, on a chain whose dominant activity is stablecoin payments. The cryptographic lineage here is well understood and deliberately unoriginal. The contribution is the layer above it.
Hushh is a shielded note pool with an identity and delivery layer built on top. Concretely, this document specifies:
What is deliberately not claimed: encrypted balances, mainnet readiness, a multi-party trusted setup, or a decentralised root poster. Each is roadmapped (§12) and each is listed as a present limitation (§10).
Nothing in this section is novel. It is included so that the specification in §4–§5 can be read without external references, and so that the security argument in §8 has named assumptions to rest on.
Let F denote the scalar field of BN254, of prime order
All circuit values are elements of F. Byte strings that enter the circuit as
field elements must be reduced mod r, and the specification is explicit about
where that reduction happens (§5.3), because an implicit reduction is a classic source of
soundness bugs. We write x ←$ S for uniform sampling from a set,
H(·) for the Poseidon permutation-based hash instantiated over F,
and ‖ for concatenation. A negligible function in the security parameter
λ is written negl(λ).
BN254 (also bn128, alt_bn128) is a pairing-friendly Barreto–Naehrig curve with native EVM
precompiles at addresses 0x06, 0x07, and 0x08 for
addition, scalar multiplication, and the pairing check respectively. Its practical
significance for Hushh is economic rather than cryptographic: an on-chain Groth16 verification
over BN254 costs a fixed and modest amount of gas, whereas the same verification over
BLS12-381 requires in-contract field emulation on chains without the corresponding
precompiles, at a cost that makes per-payment shielding uneconomic.
The cost accepted in exchange is a security level. Following the Kim–Barbulescu improvements to the tower number field sieve, BN254's effective security against discrete logarithm attacks in the target group is estimated at roughly 100 bits rather than the 128 bits originally claimed. For a testnet payment rail with fixed denominations this is an acceptable margin; it is recorded here as a stated assumption rather than left implicit, and migration to a higher-security curve is a mainnet consideration (§12).
Poseidon is an algebraic hash function designed for efficient arithmetisation: its round
function is a low-degree polynomial map over F, so expressing it as R1CS
constraints costs tens of constraints per permutation rather than the tens of thousands
required by a bit-oriented hash such as Keccak-256.
The choice is load-bearing for the product, not merely an optimisation. A depth-20 Merkle path requires 20 hash evaluations inside the circuit. With Keccak, the resulting constraint count pushes browser proving time from seconds into minutes and, on mobile hardware, into failure. Poseidon is what makes client-side proving viable, and client-side proving is what makes the "server never holds a secret" invariant (§7) achievable rather than aspirational.
The cost accepted is maturity. Poseidon has substantially less cryptanalytic history than Keccak, and its security rests on the difficulty of algebraic attacks (Gröbner basis, interpolation) against a low-degree round function. Hushh uses the circomlib instantiation with the standard parameter sets for arity 1, 2, and 4.
An incremental Merkle tree of depth d over Poseidon2 accumulates up
to 2^d leaves into a single root. Membership of a leaf is proven by supplying
d sibling hashes and d direction bits; the verifier recomputes the
root and compares. Hushh uses d = 20, giving a capacity of 1,048,576 notes, which
is a deliberate over-provision: the tree is cheap to deepen at design time and expensive to
migrate once notes exist in it.
Crucially, the direction bits must be constrained to be boolean inside the circuit. An unconstrained index bit permits a prover to interpolate between the two hash orderings and forge membership for a leaf that is not in the tree. This is stated again in §5.4 as a soundness-critical check because it is the single most commonly reported bug class in production Merkle circuits.
Groth16 is a preprocessing zk-SNARK for R1CS with three properties Hushh depends on:
The cost accepted is a circuit-specific trusted setup. The proving and verifying keys are derived from structured reference string material whose generation involves secret randomness ("toxic waste"). Retention of that randomness by any single party permits forging accepting proofs for false statements, which in Hushh means minting withdrawals against notes that were never deposited. It does not permit deanonymising users. This asymmetry matters and is developed in §8.4.
The security claims of §8 hold under: (A1) knowledge soundness of Groth16 over BN254;
(A2) collision resistance of Poseidon over F at the arities used; (A3) honest
generation and destruction of trusted setup randomness by at least one participant;
(A4) discrete logarithm hardness in BN254 at the ~100-bit level; (A5) IND-CCA security of the
note encryption scheme (§7.4). Failures of A1–A3 are theft-class; failures of A5 are
metadata-class. None of A1–A5 failing alone breaks on-chain unlinkability.
| System | Relation to Hushh |
|---|---|
| Zerocash / Zcash | Origin of the note-commitment-plus-nullifier construction with encrypted amounts. Hushh adopts the structure and deliberately omits encrypted balances in v1, trading confidentiality for a smaller, auditable circuit. |
| Tornado Cash | The direct architectural ancestor: fixed denominations, a Merkle set of commitments, a nullifier revealed at withdraw. Hushh's departures are the binding of recipientDigest, amount, and tokenHash into the commitment (§5.5), an off-chain tree with a posted-root window (§6.2), and the identity and delivery layer (§7.2). |
| Railgun / Aztec | Full shielded pools with arbitrary encrypted amounts and in-pool transfers. Strictly stronger privacy, substantially larger circuits and trust surface. This is Hushh's v3 direction (§12), not its v1 claim. |
| Stealth addresses (ERC-5564) | Solve recipient-address unlinkability without a pool, but leave the funding edge and the amount fully public. Complementary rather than competing: Hushh's recipientDigest is designed to accept a stealth-derived digest, which is the P1 path to repeat-payment unlinkability. |
| Goal | Statement |
|---|---|
| G1 · Unlinkability | No on-chain data connects a deposit transaction to the withdraw transaction that spends it, beyond membership in a root shared with every other note in the tree. |
| G2 · Non-custody | No party other than the holder of a note's secret can cause that note to be spent. This includes the contract owner, the indexer, the backend operator, and the protocol authors. |
| G3 · Liveness isolation | Every off-chain component is a liveness dependency and never a safety one. A total compromise of all off-chain infrastructure must not enable theft or deanonymisation. |
| G4 · First-attempt usability | A user with no cryptographic background completes a private payment correctly on the first try, addressing a human rather than a hex string, with no manual note handling. |
| G5 · Client-side secrets | Secrets, witnesses, and proofs are generated in the recipient's browser. No secret is transmitted, logged, or recoverable from anything the servers hold. |
| G6 · Calibratable risk | The system's limits are published in the same document as its claims, quantitatively where possible, so users can reason about actual exposure. |
These are excluded on purpose. Each is a real property that Hushh v1 does not have.
deposit remains callable by contracts, but no scheduling infrastructure is provided.Deployment target is not incidental to a privacy design; it determines the size of the anonymity set, which is the quantity the entire guarantee scales with.
| Property of Arc | Consequence for Hushh |
|---|---|
| USDC as native gas and settlement asset | Users hold and reason in a single unit. There is no secondary gas token to acquire before making a private payment, and no volatile denominator complicating fixed-note sizing. The onboarding step most likely to leak metadata (buying gas) disappears. |
| Full EVM equivalence | Solidity contracts, Hardhat tooling, and the snarkjs-exported verifier pattern port directly. BN254 precompiles are available at predictable cost. |
| Predictable, low fees | Groth16 verification is a fixed, non-trivial gas cost. Fee predictability is what makes per-payment shielding economically ordinary rather than a luxury reserved for large transfers. |
| Fast, deterministic finality | The indexer posts roots promptly without deep reorg handling, keeping deposit-to-withdrawable latency short and simplifying the crash-recovery path (§7.1). |
| Payments-oriented ecosystem | The load-bearing point. A shielded pool's privacy scales with the number of unspent notes it holds. Deploying on a general-purpose chain where payments are a minority of activity produces a thin set; a chain concentrated on stablecoin settlement is where a USDC-denominated anonymity set can plausibly grow. |
The protocol proper is four steps. Everything else in this document exists to make those four steps usable, or to state precisely what they do and do not guarantee.
| Party | Role | Trusted for |
|---|---|---|
| Sender | Generates the note secret, computes the commitment, deposits, and seals the note payload to the recipient's encryption key. | Correct generation of their own note. Nothing else. |
| Recipient | Decrypts the note, fetches a witness, proves membership in-browser, and submits the withdraw. | Custody of their own secret. |
| Pool contract | Custody, proof verification, nullifier set, token registry. | Safety. This is the only safety-critical on-chain component. |
| Indexer | Watches Deposit events, maintains the tree, posts roots, serves witnesses. | Liveness only. Cannot forge a leaf or learn a secret. |
| Backend | Wallet auth, @username resolution, encrypted inbox storage and delivery. | Liveness and metadata only. Holds ciphertext, never plaintext. |
| Contract owner | Token registry administration, verifier and root-manager replacement, pause. | Availability and correct upgrade. Has no withdrawal authority. |
Each user holds two independent keypairs, deliberately separated by function:
personal_sign. This key is public-facing and is
assumed to be linkable to the user.(sk_h, pk_h), used only to seal
and open note payloads. pk_h is published in the username registry;
sk_h never leaves the client. It is deterministically derived from a signature
over a fixed domain-separation string so that it is recoverable from the wallet alone, with
no separate backup:# deterministic derivation of the Hushh encryption key
sig = wallet.personal_sign("hushh-protocol/v1/encryption-key")
seed = SHA-256(sig)
sk_h = clamp_x25519(seed)
pk_h = X25519_base_mul(sk_h)
Losing the wallet loses the ability to open future notes but not the funds in already-decrypted notes, provided the note secrets were retained. Conversely, a wallet compromise exposes correspondence, not the pool. The separation is intentional: the key that can spend and the key that can read are different keys with different exposure profiles.
A note is the unit of value in the pool. It exists in three representations, and the distinction between them is where most confusion about shielded pools originates.
| Representation | Contents | Where it lives |
|---|---|---|
| Plaintext note | (secret, recipientDigest, amount, token) | Sender's browser at creation; recipient's browser after decryption. Never anywhere else. |
| Commitment | C = H₄(secret, recipientDigest, amount, tokenHash) | On-chain, as an event field and a tree leaf. Reveals nothing about its preimage. |
| Sealed payload | Enc(pk_h, plaintext note) | Backend inbox, as opaque ciphertext. |
The recipientDigest is a field element that binds the note to its intended
payout. In v1 it is derived from the recipient's wallet address; the P1 upgrade replaces this
with a per-payment stealth digest derived via ECDH, so that repeat payments to the same person
no longer share an on-chain tag. The circuit is unchanged by that upgrade because it treats the
digest as an opaque public input.
The protocol is specified as four algorithms. Setup runs once per deployment;
the rest run once per payment.
Setup(1^λ) → (pk, vk, addr) 1. (pk, vk) ← Groth16.KeyGen(R_withdraw) # §5.1 2. deploy WithdrawVerifier(vk) 3. deploy MerkleRootManager(window = 64) 4. deploy BulletPool(verifier, rootManager) 5. tree T ← empty Poseidon tree, depth 20 6. rootManager.postRoot(root(T)) # empty-tree root Deposit(pk_h^recipient, amount, token) → (C, note) 1. secret ←$ F # 254-bit, client-side CSPRNG 2. recipientDigest ← Digest(recipient) 3. tokenHash ← uint256(uint160(token)) 4. C ← H₄(secret, recipientDigest, amount, tokenHash) 5. assert token ∈ BulletPool.registry 6. assert amount ∈ DENOMINATIONS 7. ERC20(token).approve(BulletPool, amount) 8. BulletPool.deposit(token, amount, C) # emits Deposit(C, leafIndex, …) 9. ct ← Enc(pk_h^recipient, (secret, recipientDigest, amount, token)) 10. POST /notes { commitment: C, ciphertext: ct } # the plaintext note is discarded by the sender after this point Index() # continuous, off-chain on Deposit(C, leafIndex) at block b: 1. wait until confirmations(b) ≥ K 2. assert leafIndex = |T| # strict ordering; else resync 3. T.insert(C) 4. rootManager.postRoot(root(T)) # relayer key Withdraw(note, recipientAddr) → tx 1. ct ← GET /notes ; note ← Dec(sk_h, ct) # in browser 2. (path, indices, root) ← GET /witness/:C 3. assert rootManager.isKnownRoot(root) 4. nullifier ← H₁(note.secret) 5. π ← Groth16.Prove(pk, x, w) where x = [root, nullifier, recipientDigest, amount, tokenHash] w = [secret, path[20], indices[20]] 6. BulletPool.withdraw(π, root, nullifier, recipientDigest, recipientAddr, token, amount) 7. PATCH /notes/:id/claim # UI bookkeeping only
Compare the on-chain footprints. Deposit publishes
(C, leafIndex, token, amount) and is signed by the sender.
Withdraw publishes (nullifier, recipient, token, amount, root, π)
and may be signed by anyone. The intersection of these two sets is
(token, amount) — which is shared with every other note of that denomination —
and root, which is shared with every note in the tree. C never appears in a
withdraw. The nullifier never appears in a deposit. There is no field to join on.
Amounts are quantised to a small preset ladder, currently 1 / 10 / 50 / 100 USDC in base units of 106. Larger payments are composed of several notes, each deposited separately and each withdrawable separately.
The reason is amount-matching resistance. If arbitrary amounts were permitted, a withdrawal of 4,317.62 USDC would identify its deposit uniquely regardless of the cryptography between them; the anonymity set would collapse to one. Quantisation forces every note of a given size into a single indistinguishable class. The cost is a UX quantisation and a larger number of transactions for large payments, and a second-order leak discussed in §9.2: composing an unusual total from an unusual multiset of denominations is itself a fingerprint.
Only the withdraw path requires a circuit. A deposit is a commitment, an ERC-20 transfer, and an event — all publicly verifiable without zero knowledge. Halving the circuit surface this way is the single largest reduction in audit burden available to a note-pool design.
Let R_withdraw be the relation containing pairs (x, w) where the
public statement x and private witness w are
and (x, w) ∈ R_withdraw if and only if all three of the following hold
simultaneously:
C = H₄(secret, recipientDigest, amount, tokenHash). The commitment is the
Poseidon hash of the private secret together with the three public note attributes.
Hashing C upward along pathElements with ordering given by
pathIndices reproduces root. Formally, with h₀ = C
and for i ∈ [0,20):
h_{i+1} = H₂(indices[i] ? (pathElements[i], h_i) : (h_i, pathElements[i])),
and h₂₀ = root.
nullifier = H₁(secret), using the same secret witnessed in C1. This
is what makes double-spending impossible: a note has exactly one nullifier, deterministically,
and it cannot be spent under a different one.
The withdraw is accepted on-chain iff a Groth16 proof for this relation verifies against the locked public inputs, the root is inside the acceptance window, and the nullifier is unspent (§6.4).
C is deliberately absent from the public inputs — publishing
it would reconstruct the deposit-to-withdraw link and defeat the entire construction.Constraint count determines proving time, which determines whether client-side proving is viable, which determines whether the "no secret leaves the browser" invariant survives contact with product requirements. The dominant term is the Merkle path.
| Component | Count | Notes |
|---|---|---|
| Merkle path, 20 × Poseidon₂ | dominant | Roughly 240 constraints per Poseidon₂ evaluation in the circomlib instantiation, so on the order of 5k constraints total. This is the term to optimise if depth changes. |
| Path index selectors, 20 × mux | small | Two constraints each for the conditional swap, plus one booleanity constraint per bit. |
| Commitment, 1 × Poseidon₄ | small | Single evaluation. |
| Nullifier, 1 × Poseidon₁ | small | Single evaluation. |
| Public-input plumbing | negligible | Equality constraints binding computed values to declared signals. |
Order-of-magnitude figures. Replace with measured values from snarkjs r1cs info after zk:build.
Practically: a circuit in the low thousands of constraints proves in the low seconds in a desktop browser via snarkjs and WebAssembly, and in a small number of seconds on recent mobile hardware. This is the regime the design targets. A Keccak-based Merkle path, at roughly 150k constraints for the same depth, would push proving into minutes and force the proving step onto a server, which would reintroduce the secret-handling trust that the architecture exists to eliminate.
All five public inputs are field elements. Two require explicit encoding discipline:
tokenHash = uint256(uint160(tokenAddress)) # 160 bits, always < r recipientDigest = uint256(uint160(recipientAddr)) # v1; 160 bits, always < r amount = raw ERC-20 base units # e.g. 10_000_000 for 10 USDC nullifier = H₁(secret) # native field element root = Poseidon tree root # native field element
Both address-derived values fit in 160 bits and therefore reduce trivially. The contract
derives tokenHash identically in BulletPool.tokenHashOf, so the
on-chain and in-circuit encodings cannot drift. When the P1 stealth digest replaces the v1
address digest, recipientDigest becomes a full field element derived from an ECDH
shared secret, and the reduction must then be performed explicitly and identically on both
sides. That change is flagged here because a silent mismatch between an in-circuit reduction
and an on-chain one produces proofs that verify against a statement other than the one the
contract believes it is checking.
Four checks carry the security of the circuit. Each is called out for adversarial testing rather than assumed correct.
| Check | Failure consequence |
|---|---|
| Index bit booleanity | An unconstrained pathIndices[i] lets a prover interpolate between the two hash orderings at level i and forge membership for a leaf that is not in the tree. This is the most frequently reported bug class in production Merkle circuits and must be tested with a witness containing a non-boolean index. |
| Nullifier derivation | If the nullifier is not constrained to derive from the same secret as the commitment, a note can be spent repeatedly under fresh nullifiers. This is a direct drain of the pool and is the highest-severity failure in the system. |
| Public input ordering | The order [root, nullifier, recipientDigest, amount, tokenHash] is locked. A mismatch between the circuit's declared order and the verifier contract's argument order silently changes the statement being proven. |
| Verifier replacement | The mock verifier used before zk:build accepts everything. Deployment scripts must assert that the live verifier is the generated WithdrawVerifier and not the mock. A status check for this is part of pnpm status. |
A Groth16 proof sitting in the mempool is public data. Without countermeasures, an observer could lift it, resubmit it with their own address as the payout target, and steal the note by winning the ordering race. This class of attack has drained real deployments.
Hushh forecloses it structurally rather than procedurally. recipientDigest,
amount, and tokenHash are all inside the commitment (C1) and all
public inputs of the proof. The contract checks that the recipient argument of
withdraw matches the recipientDigest that the proof commits to.
Changing the payout address changes a public input, which changes the statement, which
invalidates the pairing check. There is nothing to steal: a proof pays only the address it
was generated for.
The same argument covers value: an observer cannot up-value a withdrawal, because
amount is likewise bound. And it covers token substitution, because
tokenHash is bound. The proof is a bearer instrument only in the sense that anyone
may submit it — which is a feature, since it permits a third-party relayer to pay gas
on the recipient's behalf without being trusted with anything (§12).
| Contract | Role | Criticality |
|---|---|---|
BulletPool.sol | Custody of deposited notes; deposit and withdraw entrypoints; the nullifier set; an embedded registry of accepted tokens; pause; Ownable2Step; reentrancy guard. | Safety-critical. All funds sit here. |
MerkleRootManager.sol | Rolling window of the 64 most recent valid roots, with isKnownRoot() consulted at withdraw time. | Safety-critical for soundness of membership. |
WithdrawVerifier.sol | Groth16 verifier generated by snarkjs at circuit build; replaces the mock after zk:build. | Safety-critical. Generated, not hand-written. |
MockUSDC.sol | Test ERC-20 standing in for USDC during testnet operation. | Testnet only. |
Verifier and root manager are swappable by the owner. This is an availability affordance — a circuit rebuild or a root-manager fix should not require migrating custody — and it is also the largest owner-held power in the system. It is recorded honestly in the trust matrix (§8.5): an owner who installs a malicious verifier can drain the pool. Timelocking this replacement is a mainnet prerequisite.
A naive design accepts only the single current root. That design is broken in practice. Between the moment a recipient fetches their witness and the moment their transaction is mined, any other user's deposit advances the root and invalidates their proof. The recipient must re-fetch, re-prove, and resubmit — and loses the race again if the pool is still busy. The failure rate scales with pool activity, meaning the system fails hardest exactly when the anonymity set is strongest and users most want to transact.
Window sizing is a straightforward liveness calculation. Let λ_d be the deposit
arrival rate and T_p the time from witness fetch to inclusion (proving plus
signing plus block time). A proof survives if fewer than W deposits land during
T_p. Under a Poisson arrival model the failure probability is
With W = 64, failure requires 64 deposits inside a single proving window. At
demo volumes this is unreachable; at a sustained one deposit per second and a 20-second
proving window, the expected count is 20 and failure remains rare. The parameter should be
revisited if sustained deposit rate ever approaches W / T_p, and the cost of
increasing it is bounded storage plus a slightly longer replay horizon.
The pool rejects the transaction unless every check passes, in this order. The ordering is not cosmetic.
1. isKnownRoot(root) # root is inside the 64-entry window 2. !nullifiers[nullifier] # the note has not already been spent 3. verifyProof(π, publicInputs) # Groth16 pairing check, locked input order 4. registry[token] && amount ∈ DENOMINATIONS 5. recipientDigest binds recipient --- effects --- 6. nullifiers[nullifier] = true # mark spent BEFORE the transfer 7. ERC20(token).transfer(recipient, amount) 8. emit Withdrawal(nullifier, recipient)
Cheap checks precede the expensive pairing check so that malformed transactions fail early and cheaply. More importantly, step 6 precedes step 7: the nullifier is written before value moves, so a reentrant callback from a hostile token encounters a note that is already spent. This is checks-effects-interactions applied to the one state transition that matters, and it is enforced by a reentrancy guard as well, on the principle that the ordering argument should not be the only thing standing between the pool and a drain.
| Operation | Dominant cost | Indicative |
|---|---|---|
deposit | ERC-20 transferFrom, one storage write for the leaf counter, one event. | ~60–90k gas |
withdraw | Groth16 pairing check via BN254 precompiles, one storage write for the nullifier, ERC-20 transfer, one event. | ~280–350k gas |
postRoot | One storage write into the ring buffer. Paid by the relayer, not the user. | ~30–45k gas |
Indicative ranges based on typical BN254 Groth16 verification costs. Replace with measured values from a Hardhat gas report against the deployed build.
The economically relevant observation is that withdraw is a constant
cost, independent of pool size. A pool with a million notes verifies as cheaply as one with
ten. Privacy therefore does not become more expensive as it becomes stronger — an unusual and
favourable property, and the reason Groth16 was chosen over proof systems with better setup
characteristics but logarithmic or linear verification.
Six layers, with a hard boundary between what runs on the client and what runs on a server. Every secret lives above the boundary. Every server below it is a liveness dependency and never a safety one.
The indexer watches Deposit events, inserts leaves in strict
leafIndex order, and posts the resulting root using a dedicated relayer key. It
exposes /health, /root, /witness/:commitment,
/deposit/:commitment, and /stats. It never returns a secret and never
generates a proof.
Three operational properties matter more than they appear to:
leafIndex = |T| before
every insert. A gap means events were missed, and the correct response is to resync from the
chain rather than to insert out of order, which would produce a root no honest party can
reproduce.K
confirmations, so a reorg cannot bake a reverted deposit into the tree. Arc's fast
deterministic finality keeps K small and therefore keeps deposit-to-withdrawable
latency short.Every leaf is derived from a public Deposit event. Any party — a user, a
watchdog, a competitor — can independently rebuild the tree from the event log and check that
a posted root matches. A malicious indexer that inserts a fabricated leaf publishes a root
inconsistent with the chain's own history, and that inconsistency is detectable by anyone who
looks. The indexer's real power is withholding: it can decline to post roots or to
serve witnesses, stalling withdrawals. That is a liveness attack, and §12 addresses it by
making root posting permissionless.
Steps 1–4 of §4 are sufficient for privacy and insufficient for adoption. Three components carry the user experience, and the design constraint on all three is that none may be trusted with funds or secrets.
| Layer | Function | What it can see |
|---|---|---|
| Username registry | Resolves @handle to a wallet address and a Hushh encryption public key, so senders address humans rather than hex. | Public directory data only. |
| Encrypted inbox | Note payloads sealed to the recipient's Hushh public key, stored server-side as opaque ciphertext and delivered on login. | Ciphertext and routing metadata: who messaged whom, and when. |
| Proof SDK | Generates secrets, commitments and nullifiers; assembles Merkle witnesses; produces Groth16 proofs entirely in the browser. | Runs client-side only. Sees everything, transmits nothing. |
The backend stores ciphertext and routing metadata. It never holds a secret, never generates a proof, and cannot spend a note. A total compromise of the Hushh backend leaks correspondence metadata. It does not leak funds and does not break on-chain unlinkability. This sentence is the design constraint every backend feature is checked against; a feature that would violate it is not built.
The sealed payload uses ephemeral-static X25519 key agreement with an AEAD, which gives sender anonymity within the ciphertext itself and forward secrecy against future compromise of the ephemeral key:
(esk, epk) ←$ X25519 keypair # fresh per note
shared = X25519(esk, pk_h^recipient)
key = HKDF-SHA256(shared, info="hushh/v1/note")
ct = XChaCha20-Poly1305.Seal(key, nonce, note)
payload = epk ‖ nonce ‖ ct
The recipient scans their inbox, attempts decryption with sk_h, and keeps what
opens. Note that the payload carries no sender identifier: the backend learns who posted
a ciphertext from the authenticated session, but the ciphertext itself does not name a sender.
A user who prefers not to leak that routing metadata can bypass the inbox entirely and deliver
the payload out of band by copy-paste, which the SDK supports and which costs nothing in
on-chain privacy.
A malicious registry could serve its own pk_h in place of the intended
recipient's, and thereby read note payloads. It could not spend those notes, because
spending is gated by the on-chain proof against recipientDigest. For
high-value payments, verify the counterparty's key out of band. The v2 on-chain identity
registry (§12) removes this seam by moving handle-to-key binding on-chain, where
substitution is public and detectable.
One rule, enforced without exception: the frontend calls the SDK and never reimplements cryptography. Poseidon, commitment derivation, nullifier derivation, witness assembly, and proving exist in exactly one place. A fix lands once, an audit has one target, and no UI change can silently alter a security-relevant computation. Convenience reimplementations in application code are the mechanism by which correct protocols become incorrect products.
The question worth asking of any privacy system is not "is it encrypted" but "what does each party gain by defecting". This section defines the properties formally, sketches why they hold, and then answers that question party by party.
For every honestly generated note whose commitment has been inserted under a root still
inside the acceptance window, the recipient can produce a proof that withdraw
accepts. Follows from Groth16 completeness and the window-sizing argument of §6.2.
No probabilistic polynomial-time adversary can cause the pool to pay out more than the
total deposited, except with probability negl(λ). Equivalently: each accepted
withdraw can be mapped injectively to a distinct prior deposit of
the same token and amount.
Sketch. An accepting proof yields, by knowledge soundness (A1), an extractable
witness (secret, path, indices) satisfying C1–C3. C2 places
C = H₄(secret, ·) under a root in the window. Roots are posted only by insertion
of observed Deposit commitments, so C corresponds to a real
deposit unless the adversary found a Poseidon collision (A2) or the setup was compromised
(A3). Injectivity follows from C3 plus the nullifier set: two accepted withdrawals mapping
to the same deposit would require the same secret, hence the same
H₁(secret), hence a repeat nullifier, which step 2 of §6.3 rejects.
A note can be withdrawn at most once. Immediate from P2's injectivity argument: the nullifier is a deterministic function of the secret, so a second spend necessarily collides with the first in the spent set.
Consider an adversary who observes the full chain, chooses two honest deposits
D₀ and D₁ of the same token and denomination, and is then shown a
withdrawal spending D_b for uniform b. The adversary's advantage in
guessing b is negligible with respect to on-chain data alone.
Sketch. The withdraw transaction publishes (root, nullifier,
recipientDigest, amount, tokenHash, π). Of these, amount and
tokenHash are equal for D₀ and D₁ by construction, and
root contains both. nullifier = H₁(secret) is independent of the
commitment's other inputs and, under A2, is unlinkable to C without
secret. π is zero-knowledge, so it is simulatable from
x alone. Hence the adversary's view is identically distributed for
b = 0 and b = 1.
The qualifier "on-chain data alone" is doing real work. P4 says nothing about timing correlation, network-level observation, off-chain metadata, or the size of the candidate set. Those are treated quantitatively in §9 and honestly in §10.
A proof observed before inclusion cannot be modified to pay a different address, a different amount, or a different token. Immediate from §5.5: all three are public inputs bound inside the commitment, so altering any of them invalidates the pairing check.
| Party | Steal | Censor | Deanonymise | Notes |
|---|---|---|---|---|
| Indexer / relayer | No | Yes | No | Can withhold roots or witnesses, stalling withdrawals indefinitely. Cannot forge a leaf: the pool's own Deposit log constrains what any honest reconstruction of the tree can contain, and inconsistency is publicly detectable. |
| Backend | No | Yes | Partial | Holds ciphertext and routing metadata. Learns who messages whom and when. Does not learn amounts, secrets, or the on-chain link. Can refuse to serve the inbox, which is why local note export exists. |
| Contract owner | No* | Registry | No | Can add or remove accepted tokens and pause. *Can replace the verifier, and a malicious verifier drains the pool. This is the largest owner power in the system and is the reason a timelock is a mainnet prerequisite. |
| Trusted setup | If retained | No | No | Toxic-waste retention permits forged proofs, which is theft from the pool. It does not permit deanonymising users — a critical asymmetry, since it means a bad ceremony risks funds but not the safety of people who used the system for privacy. |
| Chain validators | No | Yes | No | Standard L1 censorship assumptions. Proof contents remain opaque to block producers. |
| Network observer | No | No | Timing | Can correlate deposit and withdrawal timing in a thin pool, and can correlate IP addresses across deposit and withdraw if the user does not change network. Mitigated by pool depth, by delay, and by transport-level precautions. |
Custody and unlinkability rest exclusively on the contracts and the circuit. Every off-chain component is a liveness dependency. A malicious indexer and a malicious backend, colluding, can stop Hushh from working. They cannot take a user's funds and cannot reconstruct the deposit-to-withdraw link.
| Attack | Mitigation |
|---|---|
| Double withdrawal | The nullifier is deterministically derived from the secret inside the circuit. Two withdrawals of one note necessarily produce the same nullifier, and the second is rejected by the spent-set check before the pairing check even runs. |
| Proof front-running | recipientDigest is a locked public input bound inside the commitment. A proof lifted from the mempool pays only the address it was generated for; redirecting it invalidates the pairing check (§5.5). |
| Forged Merkle root | Roots are constrained by observable on-chain Deposit events. Any party can independently rebuild the tree and detect a root inconsistent with the event log. Detection is public; v2 makes posting permissionless so detection also has a remedy. |
| Reentrancy on withdraw | The nullifier is written before the ERC-20 transfer, so a reentrant callback from a hostile token encounters an already-spent note. A reentrancy guard is applied in addition, not instead. |
| Amount fingerprinting | Fixed denominations only. A withdrawal of a non-standard amount — the classic correlation vector — is not expressible in the system. |
| Backend key substitution | A malicious registry could serve its own encryption key and intercept ciphertext, though not funds. Verify keys out of band for high-value payments; the v2 on-chain registry closes the seam (§7.3). |
| Malicious token in registry | Only owner-registered tokens are accepted. A fee-on-transfer or rebasing token would break the fixed-denomination invariant, so the registry admits only standard-behaviour ERC-20s. This is an owner responsibility and is listed as such. |
| Weak client randomness | The note secret is sampled from the browser CSPRNG. A predictable secret is a stealable note, and this is entirely a client-side property — no amount of on-chain design compensates for it. The SDK is the only place secrets are generated, which is why §7.4 forbids reimplementation. |
| Denial by root withholding | Currently unmitigated: a stalled relayer blocks all withdrawals. Funds are not at risk and the stall ends when posting resumes. Permissionless posting (v2) is the structural fix. |
| Dust and probe deposits | An adversary who deposits many notes to inflate their share of the anonymity set reduces the effective set for everyone else (§9.1). This is inherent to pooled anonymity and is mitigated only by genuine volume. |
P4 is a statement about cryptography. What a user actually experiences is a number: how many other notes could plausibly have been the source of their withdrawal. That number is smaller than the pool size, and it decays with time. Users deserve the formula, not a reassurance.
Naively, the anonymity set is every unspent note in the pool. Three effects shrink it:
Denomination partitioning. A 50 USDC withdrawal can only have come from a
50 USDC deposit. The pool is partitioned by (token, denomination) and a user's set
is their partition, not the whole tree. A ladder of four denominations across two tokens
partitions the pool eight ways.
Ordering. Only notes deposited before the withdrawal are candidates.
Adversary-owned notes. An observer who deposited some of the notes in the
partition knows those are not the source. If an adversary controls a fraction f of
the partition, the honest set shrinks by that fraction.
Writing N_d(t) for the number of unspent notes of denomination d at
time t, the effective set is
and the observer's uncertainty is properly measured not by the count but by the Shannon entropy of their posterior over candidates:
The posterior is uniform only if the observer has no timing information. They almost always have timing information.
Suppose an observer models the delay between deposit and withdrawal as a distribution
g(Δ). Seeing a withdrawal at time t, they assign each candidate
deposit at time t_i the weight p_i ∝ g(t − t_i). If users withdraw
promptly, g is sharply peaked and the posterior concentrates on a handful of
recent deposits — the entropy collapses even though the raw count is large.
| Control | Effect |
|---|---|
| Wait before withdrawing | Flattens the observer's timing posterior. The strongest free control available. Withdrawing seconds after a deposit lands is close to publishing the link. |
| Use common denominations | Keeps you in the largest partition. An unusual denomination is a small partition and therefore a small set, regardless of the tree's total size. |
| Vary the composition of large payments | Paying 230 USDC as 2×100 + 3×10 at one instant is a recognisable multiset. Spreading composition over time and mixing sizes blunts it. |
| Withdraw to a fresh address | Prevents the payout address itself from re-linking the payment to an identity that the pool just spent effort concealing. |
| Separate network context | The protocol conceals the on-chain link, not the IP address. Depositing and withdrawing from the same network fingerprint gives an observer a correlation the chain does not. |
| Deliver out of band | Skipping the inbox for copy-paste delivery removes the backend's view of correspondence metadata at the cost of manual handling. |
At demo-stage volume the anonymity set is thin and a determined observer with timing data can narrow it substantially. The cryptography is not the limiting factor and improving it would not help. Volume is the limiting factor. This is a property of every pooled-anonymity system, and any project claiming otherwise at launch volume is misrepresenting the mathematics.
Stated without hedging, because users calibrate real-world risk against these sentences. Each item is a property Hushh v1 does not have.
Hushh is testnet software with an unaudited circuit and a single-contributor trusted setup. Do not use it to protect information whose disclosure you cannot afford.
| Network parameter | Value |
|---|---|
| Network | Arc Testnet |
| Chain ID | 5042002 |
| Currency | USDC |
| RPC | https://rpc.testnet.arc.network |
| Explorer | https://testnet.arcscan.app |
| Contract | Address |
|---|---|
| BulletPool | 0x9E65584C6ACE76cEFde4FD5f50E6bB9763999dCe |
| MerkleRootManager | 0x6D6530A1fC908792DA2aa533a5F299283E6F062a |
| BulletVerifier | 0x0A95c430Bf6AA3140A4Bf04C8D5982472331008d |
| MockUSDC | 0x7d7aF5715e5671e0E3126b2428Dc2629bD9061e3 |
| Service | Endpoint |
|---|---|
| App | hushh-inky.vercel.app |
| Backend | hush-protocol-backend-w7qx.onrender.com |
| Indexer | hush-protocol-indexer-iqcg.onrender.com |
Build requirements: Node 22+, pnpm 9+, Circom for circuit builds. The verifying key
withdraw_vk.json is published so that any party can independently verify a proof
off-chain and confirm that the deployed verifier corresponds to the published circuit.
Intermediate powers-of-tau and zkey material is never committed.
The sequence is deliberate: decentralise the components that can stall the system before adding features that widen its surface area. A privacy protocol that grows features faster than it sheds trusted parties ends up with a large attack surface defended by a small number of promises.
| Release | Theme | Contents |
|---|---|---|
| v1 shipped | Core rail | Fixed-denomination USDC notes · Groth16 withdraw circuit · 64-root acceptance window · username resolution · encrypted inbox · browser-native proof SDK. |
| v2 next | Decentralise the edges | Permissionless root posting with multiple independent watchers · MPC trusted setup ceremony · a relayer network enabling gasless withdrawal for recipients holding no gas · client-side witness reconstruction so a user can withdraw with no indexer at all · on-chain identity registry, closing the key-substitution seam (§7.3) · timelocked verifier replacement. |
| v3 | Richer privacy | Variable amounts via encrypted balances (Pedersen commitments plus range proofs) · note splitting and merging · multi-token shared anonymity sets · stealth digests as the default for every send, giving repeat-payment unlinkability · recurring shielded payroll. |
| v4 | Compliance optionality | Viewing keys for voluntary disclosure · selective audit trails · proof-of-innocence style attestations. Disclosure controlled by the user, never by the protocol. |
Two items deserve emphasis because they change the trust model rather than the feature set.
Client-side witness reconstruction removes the indexer from the critical path
entirely: a user who syncs Deposit events themselves needs no witness API, which
converts the indexer from a liveness dependency into a convenience. Permissionless
root posting means a stalled relayer is a nuisance rather than an outage. Together
they eliminate the only two ways the current system can be stopped.
Financial privacy and financial crime are not the same subject, and conflating them produces both bad policy and bad protocols. Hushh's position is specific:
None of this is legal advice, and the regulatory treatment of shielded pools varies substantially by jurisdiction and is unsettled in several major ones. Integrators should take their own advice.
| Principle | Why it is enforced |
|---|---|
| Never reimplement crypto in the app | The frontend calls the SDK. Poseidon and proving exist in exactly one place, so a fix lands once and an audit has one target. |
| The server holds ciphertext, never secrets | A full backend compromise must leak metadata only. Never funds, never identities, never the on-chain link. |
| Safety on-chain, liveness off-chain | Anything off-chain may stall the system. Nothing off-chain may break it. This is the line every architectural decision is checked against. |
| State the limits | Users cannot calibrate real-world risk against marketing language. Section 10 exists for that reason and is maintained as a first-class part of this document, not an appendix to it. |
| Prefer a smaller circuit | Every constraint is code that must be correct and cannot be patched after notes exist under it. Deposit requires no circuit, and that decision halved the audit surface at no cost to the guarantee. |
| Symbol | Meaning |
|---|---|
F, r | Scalar field of BN254 and its prime order. |
H₁, H₂, H₄ | Poseidon hash at arity 1, 2, and 4 over F (circomlib instantiation). |
secret | Uniform field element sampled client-side. Sole spending authority for a note. |
C | Note commitment, H₄(secret, recipientDigest, amount, tokenHash). Appears on-chain at deposit and never at withdraw. |
nullifier | H₁(secret). Appears on-chain at withdraw and never at deposit. |
recipientDigest | Field element binding the note to its payout target. v1: address-derived. P1: stealth-derived per payment. |
tokenHash | uint256(uint160(tokenAddress)). Derived identically in circuit and contract. |
root | Root of the depth-20 Poseidon Merkle tree of commitments. |
W | Root acceptance window size. Currently 64. |
d | Tree depth. Currently 20, giving capacity 2²⁰ = 1,048,576 notes. |
(sk_h, pk_h) | Hushh encryption keypair, X25519, derived deterministically from a wallet signature. |
A_eff | Effective anonymity set: unspent notes of the same denomination, excluding adversary-owned notes. |
BulletPool
deposit(token, amount, commitment) → leafIndex
emits Deposit(commitment, leafIndex, token, amount)
withdraw(proof, root, nullifier, recipientDigest,
recipient, token, amount)
emits Withdrawal(nullifier, recipient)
addToken(token) / removeToken(token) / isSupported(token)
tokenHashOf(token) → uint256
pause() / unpause() # Ownable2Step
setVerifier(addr) / setRootManager(addr) # timelock: v2
MerkleRootManager
postRoot(root) # relayer only in v1
isKnownRoot(root) → bool # rolling window of 64
WithdrawVerifier
verifyProof(a, b, c, publicInputs[5]) → bool
Observe what is absent from the surface: there is no administrative withdrawal, no balance mapping keyed by user, and no function that accepts a commitment at withdraw time. The absence of the third is what makes the construction work; the absence of the first two is what makes it non-custodial.
publicInputs[0] = root # field element, native publicInputs[1] = nullifier # field element, native publicInputs[2] = recipientDigest # uint160 → field, v1 publicInputs[3] = amount # ERC-20 base units, e.g. 10_000_000 publicInputs[4] = tokenHash # uint256(uint160(token)) # ORDER IS LOCKED. The circuit's declared signal order and the # verifier contract's argument order must match exactly. A silent # permutation changes the statement being proven without any # visible failure until it is exploited.
| Parameter | Value | Rationale / change cost |
|---|---|---|
| Curve | BN254 (alt_bn128) | Native EVM precompiles. Changing requires a new verifier and new setup. |
| Proof system | Groth16 | Constant proof size and verification cost. Changing requires a full circuit rebuild. |
| Hash | Poseidon (circomlib) | Arithmetisation-friendly. Changing invalidates every existing commitment. |
Tree depth d | 20 | 1,048,576 note capacity. Deliberate over-provision: cheap now, migration-expensive later. |
Root window W | 64 | Liveness parameter (§6.2). Cheap to raise; raising extends the replay horizon. |
| Denominations | 1 / 10 / 50 / 100 USDC | Amount-matching resistance. Adding sizes fragments the anonymity set. |
Confirmations K | chain-dependent | Reorg safety before leaf finalisation. Small on Arc. |
| Note encryption | X25519 + XChaCha20-Poly1305 | Ephemeral-static agreement per note. Application layer; changeable without touching the pool. |
Hushh Protocol · Whitepaper v1.0 · Arc Testnet. This document supersedes the Hushh Litepaper v1.0 and is maintained alongside the implementation; where the two disagree, the implementation and its tests are authoritative and this document is a bug.
Hushh Protocol is testnet software with an unaudited circuit and a single-contributor trusted setup. Do not use it to protect information whose disclosure you cannot afford. Nothing in this document is financial, legal, or security advice.