How CHEQ3 works
What actually happens when money moves, from the note on your device to the proof the chain verifies. The repository carries README.md as the specification; this is the explanation.
The idea in one paragraph
A normal blockchain payment is a public bank statement. CHEQ3 keeps the money on the chain but takes the statement off it: the chain checks that your payment was valid without ever learning who paid whom, or how much.
CHEQ3 is a current account whose balance nobody can see. You put USDG into a shared pool and get back a private claim on it. Paying someone does not move a token from your address to theirs; it destroys your claim and creates two new ones, one for them and one for your change, and proves in zero knowledge that the arithmetic was right and that you were allowed to do it.
The chain sees that a valid settlement happened. It does not see who paid, who was paid, or how much. Because the claim is a note rather than a balance on an address, it can carry rules: an employer can hand an employee a note that may only be spent up to 500 USDG a day, only to approved suppliers, only until the end of the quarter, and the rules are enforced by the proof rather than by a server anyone has to trust.
CHEQ3 does not lend. Credit comes from its own deployment of Compound v3 on Robinhood Chain, with USDG as the base asset. CHEQ3 turns a borrower's credit line into a private note and takes no credit risk. Whether the pool also parks its idle balance in that market to earn is a deployment choice; the shipped deploy script does not.
Everything is a note
You do not have a balance. You have a stack of private IOUs, each one a file on your device. The chain only ever sees a fingerprint of each one — a hash it cannot reverse.
One note type serves credit notes, spending authorities and payments. A note is a record that lives only on its owner's device; the policy fields are zero for ordinary money.
What the chain stores is a commitment to that record. The pool computes it itself from the inner you supply and the value it just took from you, which is how the amount gets bound on chain without you being able to lie about it — one Poseidon hash on chain, and the asset is bound in-circuit against the pool's own immutable asset id.
policyHash = Poseidon(maxPerTx, dailyLimit, allowedRoot, expiry,
spentInEpoch, epoch, issuerKey)
inner = Poseidon(Poseidon(assetId, Poseidon(ownerKey, policyHash)),
Poseidon(seed, blinding))
commitment = Poseidon(inner, value)
ownerKey = Poseidon(sk, 0)
nk = Poseidon(sk, 1)A note is restricted when issuerKey ≠ 0. Restricted notes are spending authorities: the delegate named by ownerKey may spend under the policy, and the issuer may reclaim at any time. A note with no policy is ordinary money, and its owner is the only party who can do anything with it.
How a note reaches you
Nobody sends money to an address here. They wrap a note so only you can open it, and leave it in plain sight on the chain. Finding yours means checking a two-byte hint on every one that goes past — cheap enough that your browser can do it for the whole history.
One secret scalar derives everything. ownerKey = Poseidon(sk, 0) is the name a note is addressed to. nk = Poseidon(sk, 1) is what turns a note into a nullifier when you spend it. Two X25519 keypairs come off the same secret by hashing with separate domain labels: one for encryption, one for detection.
Your public recipient code is those three public values, printed as cheq3:<ownerKey>:<encPublicKey>:<detectPublicKey>. That is what you hand to someone who wants to pay you.
When someone pays you, they encrypt the note they just made for you and put the ciphertext in the settlement transaction — 299 bytes, always exactly that many, so the size says nothing. It carries an ephemeral public key, a nonce, and a 2-byte view tag derived from a Diffie-Hellman between the sender's ephemeral key and your detection key.
The tag is two bytes, so about one in 65,536 candidates is a false positive that fails to decrypt. The detection key cannot decrypt anything, so an indexer learns tag matches and never contents, amounts, policies or recipients.
What a fresh browser can and cannot recover
A sync re-derives every note by trying to decrypt the ciphertexts in each leaf, so a new device recovers the full balance from the identity alone. Two things cannot be recovered that way and are kept in local storage per identity: the allowlists an issuer handed you, because only their root is on chain, and the authorities you issued, because those are encrypted to the delegate rather than to you.
A stored allowlist is rejected unless it still hashes to the root committed in the authority, one identity's vault never leaks into another's, and an imported secret is never written to storage.
The tree, the queue, and the wait
Storing every note on the chain would be ruinously expensive, so the chain stores one number that stands for all of them. Adding new notes to that number is done in batches of sixteen, which is the reason a fresh deposit takes a few seconds to become spendable.
All commitments live in one zero-padded Poseidon Merkle tree of fixed depth 30 — empty leaf 0, Z[i+1] = Poseidon(Z[i], Z[i]). The tree is not stored on chain, which would be ruinous. The pool keeps only the current root, the last 128 batch roots, a keccak chain of what is waiting to go in, and the nullifier set.
Settlement cost used to grow with the tree: every settlement ran a Poseidon insert on chain. Now a settlement or deposit only appends to a queue, queueHash = keccak256(queueHash ‖ leaf), and a separate proof folds the next 16 leaves in at once.
insertBatch(proof, leaves[16], newRoot) consumes the queue in order. The contract recomputes the keccak chain over the supplied leaves and compares it with the stored one — a checkpoint every 16 enqueues lets full batches be verified while more leaves keep arriving — requires unused slots to be zero, and verifies that the subtree at startIndex was empty under the old root and that newRoot is the same tree with that subtree replaced.
Anyone can submit a batch, because the inputs are public, so liveness does not depend on a privileged party. Until its batch lands a note is queued: visible to its owner, counted in pendingNotes(), not spendable. The relayer batches after every settlement and on a thirty-second timer, so in practice the wait is seconds. The backlog is capped at 4,096 waiting deposits, so a stalled batcher cannot let the queue grow without bound.
Poseidon(inner, value). A settlement puts its two outputs in one leaf, a three-input Poseidon(out1, out2, 1). Those are Poseidon permutations of width three and four, so no pair of field elements can be read as both; if they could, a one-unit deposit could be passed off as half of a settlement worth anything you like.Why the batch is aligned, and what it costs
Batches are aligned to 16 slots. A partial batch wastes the remaining slots, which does not matter when 230 are available — about 67 million operations even if every batch were spent on a single leaf. The queue refuses a leaf the tree could not hold before taking anyone's money.
A proof stays valid across the 128 most recent roots. Above that throughput between proving and inclusion, a user re-proves. Lookup is by sequence number, so the cost does not depend on how old the root is.
A deposit, and a credit draw
Two ways in. Hand over money you already have, or borrow against collateral you would rather not sell — and in both cases what you get back is a private note rather than a visible balance.
A deposit
Your wallet makes a note for itself, computes inner, approves the pool for what that principal costs today, and calls deposit. The pool pulls exactly that many tokens and checks the balance actually moved, credits the principal, refuses anything below one USDG or anything whose cost rounds to zero, supplies everything above its liquidity buffer to Comet if a market is attached, computes the commitment, queues it, and emits it with the value and your ciphertext.
Your deposit is public: the amount, your address and the commitment. Nothing links it to anything you later spend.
A credit draw
With collateral in the Comet market and manager rights granted to the adapter, you can borrow straight into a private note. CometAdapter.draw asks the pool what the principal costs right now, checks the borrow would not leave your debt below Comet's minimum, calls withdrawFrom under Comet's allow() delegation — spending any positive balance before borrowing — and deposits the USDG as a note owned by you, in the same transaction.
You now owe Comet, publicly, and hold a private claim for the same value. The adapter never holds funds between transactions and its only Comet write names the base token, so it cannot touch your collateral.
Health is enforced by the Comet engine at the moment of the draw, and never again by the pool. That is deliberate: after entry, nothing about a payment touches the credit position, so there is no settlement-time link for anyone to correlate. The cost is that credit drawn into the pool is a fixed balance — the underlying position can still be liquidated on the lending side, which is that protocol's concern.
Deposits are not denominated. A credit draw is whatever the treasury needs, and the depositor is public anyway through the lending protocol. Round deposits still help.
Paying someone
Paying does not move anything. It destroys one of your notes and creates two new ones — the payee's and your change — and hands the chain a proof that the sums add up. The chain checks the proof and learns nothing else.
This is the heart of it. Say you hold a 500 USDG note and want to pay 80.
Your wallet builds the whole settlement locally. It picks your input note, makes an output note for the recipient worth 80 and a change note for itself worth the rest minus the fee, and assembles a witness: the Merkle path proving your input is in the tree, the nullifier that will burn it, the two output commitments, and the public values packed into three words. Then it proves, in a Web Worker, that everything holds together. The first payment on a device downloads about 28 MB of proving artifacts; after that they are cached for a year.
The finished bundle goes to a relayer, which pays the gas so a private payment does not have to come from a funded public address of yours. The relayer cannot alter it: its own address and its fee are inside the proof, so changing either invalidates it. With no relayer configured the browser wallet submits the settlement itself, which makes the sender public and links that account's payments to each other — the console says which is in force.
Then it verifies the proof, marks the nullifiers spent, queues the settlement leaf, emits the event carrying both ciphertexts, and pays out the public leg if there is one. State is written before any token moves, and all three entry points carry a transient-storage reentrancy lock.
The recipient's wallet sees the tag, decrypts the note, and once a batch lands it is spendable. Nothing in the transaction says who you were or what the note was worth.
One circuit for every transition
Handing out a budget, paying someone, cashing out and clawing a budget back all run through the same piece of maths. That is deliberate: if they used different proofs, an observer could tell them apart.
Spend(30, 8) consumes one note — optionally two, for unrestricted notes owned by the same key — and creates two. The same circuit and the same verifier serve every kind of transition, which is exactly why settlements are indistinguishable on chain.
What the circuit enforces. The SDK mirrors these for early errors, but the circuit is the authority:
- Value conservation:
in + in2 = out1 + out2 + publicAmount + fee, every term range-checked to 64 bits - Ownership: the spender's key derives the note's owner key, or the issuer key when reclaiming
- Membership of the input commitment under the claimed root, at a pinned depth rather than a free one
- The per-transaction cap, the daily limit (epoch =
timestamp / 86400), the expiry, and the recipient allowlist over a depth-8 tree of owner keys - Delegates cannot sub-delegate and cannot exit to a public address
- Change keeps the input policy with
spentInEpochandepochadvanced, and epochs only move forward along a note chain - A second input is accepted only for unrestricted notes, must be a different note, and is proven under the same root
The twelve public signals, and what they bind
Two audit outputs and the output leaf, then root, nullifier, nullifier2, outCommitment1, outCommitment2, three packed words and a binding hash.
packedRecipient = recipient | publicAmount << 160 packedRelayer = relayer | fee << 160 packedAsset = assetId | timestamp << 160 ciphertextHash = keccak256(chainId ‖ pool ‖ ct1 ‖ ct2) >> 8
Every component is range-checked in the circuit, so the decomposition is unique. assetId is the pool's own immutable, derived from the token and the pool address, so a note is specific to one pool. The binding hash ties a settlement to one chain and one pool and makes the ciphertexts unforgeable — nobody can take your proof, swap in garbage ciphertexts and strand the recipient's money.
publicRecipient, relayer and ciphertextHash are squared inside the circuit so their verification-key entries are non-zero: a Groth16 public input appearing in no constraint would be free to change after proving.
Measured in Node on an M-series laptop. In the browser snarkjs runs in a dedicated worker, so the tab stays responsive while a spend is proved, and a payment proves in a few seconds.
Cashing out
Taking money out is the one moment an amount becomes visible, so you may only take it out in standard sizes — like withdrawing cash in notes rather than to the penny. An odd amount becomes several standard withdrawals plus a private remainder.
Leaving the pool is the one moment where an amount becomes public, and exact amounts are what let an observer match a vendor's exit to a company's deposit. So exits pay only in fixed denominations: powers of ten of the pool's exit unit. An observer can see that someone withdrew 100 USDG, and there is no way to tell which of the people who withdrew 100 USDG it was.
Every exit at a given moment shares the same multiplier, so interest does not shrink the crowd you hide in.
Withdrawing 1,234.56 USDG is split greedily into denomination-sized settlements — ten of them here — and the sub-unit remainder stays private. Amounts below the smallest denomination cannot exit at all. What actually lands in your account is the denomination times the pool's index, so it grows with interest and stops being a round number — which does not shrink the crowd, because every exit at a given moment shares the same multiplier.
Delegated spending
You can give someone a spending limit that enforces itself. No approval server, no trust: the rules travel inside the note, and a payment that breaks them cannot be proved, so it cannot happen.
This is the part that is hard to do any other way. An issuer spends an unrestricted note and makes the output a restricted note owned by a delegate, carrying a policy: a per-payment cap, a daily limit, an expiry, and the root of an allowlist of permitted recipients.
When the delegate pays, the circuit enforces all of it. The change output is the successor authority, carrying the updated running total, and its secrets are derived from the previous note's — seed' = Poseidon(seed, 1) — so the authority is a chain rather than a set of independent notes. Each spend consumes the authority and produces its successor, which serialises that delegate's spending and makes daily limits exact: different delegates never touch the same note, so concurrent spending cannot over-draw.
Reclaim
A restricted note nullifies with Poseidon(seed, commitment), and the seed is known to both parties, so the issuer can always compute the delegate's current note and spend it back. Revocation is just a reclaim, and on chain it is indistinguishable from any other settlement.
Audit trail
The circuit emits two field elements, one-time padded with Poseidon(seed, 2), from which the issuer recovers the amount and the recipient of every spend and can rebuild the payment note itself — even if the delegate never delivered it. A delegate cannot spend without producing a correct record, and nobody else can read any of it.
Unrestricted notes nullify with the owner's nk instead, so a payer cannot watch a recipient spend, and their audit pad is derived from nk too, so the record is readable by no one else. Because the seed is part of the commitment, a note has exactly one nullifier either way.
Interest, the buffer, and the credit market
Money sitting in your account is lent out for you, minus a cushion kept back so people can still cash out. You earn the market rate with nothing to claim — and the pool tells you honestly when that market is running at a small loss.
A private balance is a share of the pool, not a fixed number of tokens. The pool holds an index; a deposit buys principal at today's price and an exit pays the denomination times that index. If a Comet market is attached, everything above the liquidity buffer — 20% by default, owner-settable — is supplied to it, so idle balances earn the market's supply rate continuously with nothing to claim.
The market only builds reserves once enough of it is lent out. Below that point it pays suppliers more than borrowers bring in, and the pool reports the shortfall rather than hiding it.
Attaching a market has a cost. Comet's liveness becomes the pool's: a supply pause, a supply cap or an illiquid market stalls deposits and exits. The buffer is the cushion, and recall pulls parked funds back — though the next deposit re-parks them unless the buffer is raised first. Funds are not at risk while Comet's own accounting holds them.
Why the market can lose reserves, and how the pool reports it
Borrowers pay 0.015 + 0.05u and suppliers receive 0.054u, so reserves move by u(0.05u − 0.039) of total supply per year. Below about 78% utilisation that is negative.
The pool values its Comet claim against what Comet can actually cover, so this shows up honestly as a small haircut rather than as a surprise for whoever exits last. Seeding the market's reserves at launch avoids the regime entirely.
Share rounding always favours the pool, by at most one unit per operation — a millionth of a USDG, far below gas. The usual first-depositor donation attack does not apply, because the depositor names the principal and the pool computes the price.
How prices are read
Every collateral price comes from the feed Comet was configured with. The console reads latestRoundData() on getAssetInfo(i).priceFeed directly, so it sees what the market sees, plus the updatedAt Comet ignores.
The console never invents a price: a feed that reverts, returns a non-positive answer or reports updatedAt == 0 yields no price, and the asset is shown as unpriced and left out of any total. Scale comes from the feed's own decimals(). An answer older than 26 hours is marked stale but still shown and still counted, because Comet has no staleness check and will keep lending against it — hiding it would understate a position the chain still treats as live.
What it costs
A payment costs the same whether the pool holds a hundred notes or a hundred million — the cost stopped growing with the tree. Most of what remains is the proof check itself.
Measured on anvil with the real verifiers. Settlement no longer contains a tree insert, so the number does not move as the tree grows — the on-chain-tree design would have reached about 800k at depth 20.
What remains in a settlement is the Groth16 verifier, about 260k of the 354k, plus the nullifier write, the queue update, events and calldata. A join-split costs roughly 22k more for the second nullifier write, and the nullifier2 public input adds about 7k to every settlement.
A batch is about 400k almost regardless of how many of its 16 slots are used: 25k per leaf when full, the whole 400k when a relayer batches every settlement individually. That is the trade the relayer makes on your behalf — latency for amortisation.
Who runs what
Your browser does the work that matters. The three servers involved exist for convenience — paying gas, serving the app, saving you a chain scan — and none of them can move a token or read a balance.
Almost everything happens in the browser: the identity is derived there, notes are decrypted there, and the proof is generated there. Three server pieces exist, and none can move money.
The identity itself is derived locally from one signed message, from a development seed, or from an imported secret. Only the method is persisted, never the secret: a seed session resumes on reload, a signature session asks for the signature again.
Network and deployment
One chain, one currency, and a credit market CHEQ3 deployed itself rather than borrowed. Everything below is what has to be true before real money is involved.
CHEQ3 runs only on Robinhood Chain and settles only USDG. The relayer, indexer and local harness refuse any other chain id.
Credit comes from CHEQ3's own Comet deployment rather than a third-party market, so the base asset, the collateral list, the rate curve and the governance delay are all chosen rather than inherited. The pool is deployed after it, and the deploy script refuses to proceed unless the engine's base token is 6-decimal USDG.
What separates a development deployment from a production one
- Run the trusted setup. The keys in the repository are development keys with delta equal to gamma — anyone can forge a proof against them. A mainnet deploy refuses to run until it sees contributed keys.
- Own the pool with a multisig, and give the guardian a different key. The deploy refuses a guardian equal to the owner on mainnet.
- Set the deposit cap low for the first weeks. It is measured in principal, so interest and donations cannot push a pool over it, and exits are never blocked by it.
- Register the relayer and set the fee ceiling, at deploy or afterwards.
- Seed the market's reserves if a Comet market is attached, or leave it unattached and forgo the yield.
- Verify the contracts. It cannot be automated on this chain, so budget the manual time — unverified contracts on a privacy protocol are a trust problem, not a cosmetic one.
Because the verifiers are immutable, a circuit fault means a new pool and a migration: users exit the old pool, which stays functional for exits. That path is worth rehearsing before launch.
What is public
Put your money in, and that is on the record. Everything you then do with it is not. The two columns below are the whole disclosure surface.
Public
Deposited— commitment, value and ciphertext, on every depositSettled— both nullifiers, the leaf, both outputs, the audit record and both ciphertextsBatched— start index, count and the new root- A deposit's amount and depositor; entry is public by construction
- A public exit's amount and recipient, as one of six denominations
- The relayer's address and its fee
- The pool's total, its rate, and its queue
Private
- Who owns any note, and what it is worth
- Who paid, who was paid, and how much
- Which deposit any spend came from
- Any policy attached to a note, and any delegate's spending
- The link between any two of a user's own actions
Anonymity comes from the shared pool. Amount and timing correlation at the public boundaries is the remaining leak, and it is documented rather than solved.
Trust boundaries in plain terms
Worth reading closely. The honest answer is that one party — whoever serves you the web page — could hurt you, exactly as with any web wallet. Everyone else is boxed in by maths rather than by promises.
Settlement authority is a proof verified on chain against an immutable verifier. There is no upgrade mechanism, no proxy, no delegatecall, no fallback and no receive — so the list of things anyone can do to your money is short.
The pool owner
- Can
- Pause, set the guardian, the deposit cap, the liquidity buffer, the registered relayer and the fee ceiling, recall parked funds from Comet into the pool, and hand ownership over in two steps. Intended to be a multisig.
- Cannot
- Move a token anywhere except into the pool itself, change the token, the verifiers or the Comet address — all immutable — or alter roots and nullifiers. A pause stops deposits, settlements and batches, lapses on its own after seven days, and cannot be extended while it is running.
The guardian
- Can
- Pause, and nothing else. It is meant to be a hot key or a monitoring bot.
- Cannot
- Unpause — that is the owner's, and it replaces the guardian in the same transaction, so a compromised guardian cannot re-pause in the block it is lifted.
A relayer
- Can
- Refuse to submit your payment, and see the network-level fact that your device asked it to. That is the sharpest thing anyone can learn, and it is the reason to care which relayer you use. Within the owner's ceiling it quotes what it likes, and the console displays the rate.
- Cannot
- Redirect a payment, change an amount, raise its fee after the fact, or spend anything. Only relayers the owner registered can be paid at all.
The console host
- Can
- Serve modified JavaScript. That is the one place where a compromise reaches your keys, as with any web wallet. Nothing pins the bundle today.
- Cannot
- Sign or broadcast on your behalf — it forwards read methods only, and your wallet holds the keys.
An indexer
- Can
- Withhold data or lie about what exists, which stalls a light wallet. Its detection-key view is a 1-in-65,536 false-positive guess at which leaves belong to a wallet.
- Cannot
- Make you spend against a false tree — a light wallet recomputes every served witness against the pool's own root history and asserts the tree depth — or read amounts, policies or recipients.
Limits and known leaks
Privacy is never total, and anyone claiming otherwise is selling something. Here is what an observer can still infer, and what this design chose not to fix.
None of the following is an open defect. They are the residual risks the design accepts on purpose, and they are worth knowing before you put money in.
Reading the code
Do not take any of the above on trust. The one test file named below runs the entire protocol against a real chain with real proofs, and reads as a tour of this whole page.
Start with test/e2e/protocol.test.ts. It deploys everything to a local chain with the real verifier, runs a relayer and an indexer, and drives a company, an employee, an AI agent and two vendors through draw, allocate, pay, audit, withdraw and reclaim — with the negative cases alongside. It reads as a tour of everything above.
What the test suite covers
Contracts. 87 Foundry tests over pool accounting, roles and pause, the deposit cap, ownership hand-over, the reentrancy guard, exit denominations, the leaf queue and its checkpoints, batch verification, second-nullifier handling, and the Comet adapter against a mock engine.
End to end. The full protocol on a local chain with real proofs, including join-split consolidation, queued-until-batched semantics with a tampered batch rejected, and negative cases: limits, allowlist, in-circuit rejection, double spends, proof hijacking, ciphertext tampering, replay, third-party decryption, and a solvency check.
The console. A unit suite plus a headless-browser run that connects, syncs, pastes and verifies an allowlist, gets an over-limit payment refused, then proves and settles a permitted one in a worker in the page — asserting the vendor's private balance from outside the browser, and failing on any console error.
Alongside this page the repository carries README.md as the specification, docs/IMPLEMENTATION.md as the build reference, docs/PRODUCTION.md for running it, and docs/AUDIT.md for the security review and what the proof guarantees.
A draft is the whole idea, on one piece of paper
A payee, an amount, what it is drawn against, and a signature that proves the money was there without saying whose it was or how much was left.