Canonex documentation
Canonex is a non-custodial privacy layer for Solana built on the Privacy Pools model. You shield funds once, act privately inside a shared pool, and exit to a fresh address with a zero-knowledge proof that your funds belong to a clean association set. The proof reveals nothing about which deposit was yours.
Classic mixers give everyone the same anonymity, including thieves, which is why exchanges treat their withdrawals as suspect. Privacy Pools separates the two: honest users prove membership in a set that excludes illicit deposits, so their privacy does not come bundled with someone else's exploit.
How it works
Every action falls into one of four zones. The first and last touch the public chain. Everything in between happens inside the pool as encrypted notes and zero-knowledge proofs.
The three building blocks
- Notes. A private balance is a set of notes. Each note commits to an amount, a token, a label and a secret. Only your viewing key can read it, only your spending key can spend it.
- Proofs. Every private action is a Groth16 proof generated in your browser and verified by the Solana program. The program never sees your keys, balances or history.
- The association set. A Merkle tree of approved deposit labels, published on-chain by the Association Set Provider. Withdrawals must prove their label is in it.
Quick start
- Connect Phantom (or any Solana wallet) and create your vault. Save the recovery key shown once. Without it a lost browser means lost funds.
- Shield SOL, USDC or a supported SPL token with one signature.
- Wait for approval. Most deposits are screened within minutes. You can watch the status in the app.
- Act privately. Send to a cnx1… address, swap through Jupiter, or simply hold.
- Withdraw to a fresh address. Let some time pass and avoid exact amounts that match your deposit.
Shield
Shielding moves tokens from your public wallet into the pool vault and creates a note for you. SOL is wrapped to wSOL automatically. SPL and Token-2022 mints are supported, except tokens with features that could let a third party move or block pool funds: permanent delegates, transfer hooks, transfer fees and pausable mints.
The deposit instruction assigns the note a label, a unique identifier of this deposit. From now on every note created from this money carries the same label. The ASP judges the label, not you. See Labels and lineage.
Approval window
After a deposit lands, the Association Set Provider screens its source address and funding history. When it passes, the ASP inserts the label into the association set and publishes a new root on-chain.
| Outcome | What it means | What you can do |
|---|---|---|
| Pending | Screening in progress. Typical wait: minutes, up to [24h] for large deposits. | Hold and send privately. Withdraw after approval. |
| Approved | Label is in the clean set. All notes descended from it qualify. | Everything, including private withdrawal. |
| Declined | Funds trace to an exploit, drainer, sanctioned or high-risk source. | Ragequit back to the original address. Funds are never frozen. |
The delay is deliberate. Most hacks are identified within hours. A short window means stolen funds cannot rush through the pool before the industry has flagged them.
Send privately
A private send spends one or two of your notes and creates up to two new ones: one for the recipient, one for your change. The recipient is a shielded address starting with cnx1, which encodes their viewing public key and a checksum.
The recipient's note is encrypted to their viewing key and posted with the transaction. Their client finds it by trial-decrypting new notes. No protocol fee is charged; the relayer's network fee is paid from the notes being spent.
Swap
Swaps are routed through Jupiter from the pool vault, so the DEX sees only the pool. A swap runs in two steps:
- swap_prepare spends your input note into a single-use escrow, with the exact input amount and your minimum output bound into the proof.
- swap_execute performs the route and mints an output note for you with the same label. If the output falls below your minimum, swap_refund returns the input as a new note.
Because the minimum output is inside the proof, a relayer cannot sandwich you beyond the slippage you signed for.
Withdraw
A withdrawal proves three things at once: you own an unspent note in the pool, its label is in a recent association set root, and the recipient, relayer and fee are exactly the ones you chose. Then the program sends tokens to any address. By default the app derives a one-time address from your vault.
Good habits
- Withdraw to an address that has never touched your public wallet.
- Avoid the exact amount you deposited. Partial and round amounts blend in.
- Let time pass. A withdrawal minutes after a unique deposit is easy to match.
- Do not fund the fresh address from your main wallet afterwards. Gas is paid by the relayer.
Ragequit
Ragequit is the guarantee that nobody can lock your money. The original depositor can always exit a deposit publicly, back to the address it came from, whether or not the ASP approved it. It requires the deposit's secret and a signature from the depositing wallet.
Ragequit gives up privacy for that deposit, because the exit visibly links to the entry. It exists for declined deposits and for anyone who no longer trusts the current association set.
Notes and nullifiers
A note is never stored in the clear. The pool stores only its commitment, a Poseidon hash that binds the note's contents without revealing them:
// all hashes are Poseidon over the BN254 scalar field
precommitment = H(nullifier, secret)
commitment = H(value, mint, label, precommitment)
nullifierHash = H(nullifier)When you spend a note, you publish its nullifierHash. The program records it and refuses any second spend with the same value. Since only you know the nullifier, nobody can link the nullifierHash back to the commitment it came from. That is the core of the unlinkability: the chain learns that some note was spent, never which.
The two trees
Canonex keeps two Merkle trees. Every private withdrawal proves membership in both.
Separating the trees is what makes the design work. The state tree guarantees the money exists. The association set guarantees it is clean. Neither tree tells the verifier which leaf is yours.
Labels and lineage
A label is assigned at deposit and never changes:
scope = H(programId, mint) label = H(scope, depositIndex)
The transaction circuit enforces that every output note carries the label of the notes it spends. Money keeps its origin as it moves: if you send half of an approved deposit to a friend, their note has the same approved label. If a deposit is later found to be stolen, every note descended from it falls out of the clean set at once, while unrelated users are untouched.
The label is hidden inside the commitment. The withdrawal proof shows only that some label in the association set matches, never which one.
Association Set Provider
The ASP is an off-chain service that indexes deposits, scores their sources and publishes the clean set. It has one power: including or excluding a label.
| The ASP can | The ASP cannot |
|---|---|
| Decline a deposit from a flagged source | Move, freeze or seize any funds |
| Remove a label later if new evidence appears | Block ragequit to the original address |
| Publish a new root | See balances, transfers or who withdrew |
What gets screened
- Sanctioned addresses and entities on public lists.
- Wallets tied to protocol exploits, drainer kits and phishing campaigns on Solana.
- Rug deployer clusters and funds received directly from them.
- Deposits that hop through fresh wallets to dodge the checks above.
The scoring rules are published and versioned. $CNX holders vote on which lists the default ASP uses. Later versions allow several ASPs: a withdrawal then chooses which association set to prove against, from strict institutional sets to a minimal sanctions-only set.
What a proof proves
The Transaction(26, 2, 2) circuit spends up to two notes and creates up to two. For a withdrawal it proves, without revealing private inputs:
- Ownership. You know the nullifier and secret of each input note.
- Existence. Each input commitment is a leaf under a recent state root.
- Clean origin. The shared label is a leaf under a recent association set root.
- No double spend. Each published nullifierHash is derived correctly.
- Conservation. Inputs equal outputs plus the public amount plus fees.
- Same lineage. All inputs and outputs carry one label.
- Binding. Recipient, relayer, fee and encrypted outputs hash to extDataHash, so nobody can redirect the funds after you sign.
| Public input | Purpose |
|---|---|
| stateRoot | Must match one of the last 64 state roots |
| aspRoot | Must match one of the last 16 association set roots |
| nullifierHash[2] | Recorded to block double spends |
| outCommitment[2] | Appended to the state tree |
| publicAmount | Net tokens leaving (withdraw) or entering (shield) the pool |
| mint | Token of the pool being used |
| extDataHash | Binds recipient, relayer, fee and note ciphertexts |
Proofs are Groth16 over BN254, verified on-chain with Solana's alt_bn128 syscalls in one instruction. In-browser proving takes a few seconds on a modern laptop.
Public vs private
| Action | Visible on-chain | Sealed |
|---|---|---|
| Shield | Your address, token, amount, time | The note you received, its secret |
| Send | That a transfer happened, relayer fee, notes spent (1 or 2) | Sender, recipient, amount, token |
| Swap | Token pair and route through Jupiter, amounts | Who swapped and their balance |
| Withdraw | Recipient address, token, amount, time | Which deposit it came from |
| Ragequit | Everything: it links exit to entry | Nothing |
Fees and parameters
| Action | Protocol fee | Relayer fee |
|---|---|---|
| Shield | 0% | None, you sign directly |
| Private send | 0% | Network gas, quoted live |
| Swap | 0.20% | Network gas, quoted live |
| Withdraw | 0.25% | Network gas, quoted live |
| Ragequit | 0% | None, you sign directly |
Protocol fees go to the treasury: [50%] buys back and burns $CNX, [30%] funds relayer rewards, [20%] funds audits and the ASP. Holders above [THRESHOLD] $CNX get a reduced fee.
| Parameter | Launch value |
|---|---|
| State tree depth | 26 |
| State root history | 64 roots |
| ASP root history | 16 roots |
| Notes per transaction | 2 in, 2 out |
| Per-deposit cap | [CAP] per token, raised over time |
| Upgrade timelock | 72 hours |
Keys and recovery
Your vault is derived from one random 32-byte root seed, generated in your browser:
seed 32 random bytes spendingKey = HKDF(seed, "cnx/spend") // signs note spends inside proofs viewingKey = HKDF(seed, "cnx/view") // X25519, decrypts incoming notes oneTimeAddr_i = HKDF(seed, "cnx/ota" || i) // fresh withdrawal addresses
The seed is stored only in your browser, encrypted with your passphrase (Argon2id, then AES-256-GCM). The recovery key is shown once at setup. Note ciphertexts use X25519 key agreement and ChaCha20-Poly1305.
A viewing key can be exported read-only, scoped to a date range, for an accountant or auditor. It reveals your history but cannot spend.
Security model
- Non-custodial. Funds sit in a program-owned vault and move only with a valid proof or a depositor's ragequit.
- Bound transactions. Recipient, fee and swap limits are inside the proof. A relayer can delay a transaction, never change it.
- Isolated escrows. Each swap uses a single-use escrow account, so a failed route cannot touch the main vault.
- Limited admin. A [3-of-5] multisig can pause new deposits and register assets. It cannot pause withdrawals or ragequit, and cannot move funds.
- Timelocked upgrades. Any program upgrade is announced on-chain 72 hours before it can execute, so users can leave first.
- Trusted setup. Circuit keys come from a public multi-party ceremony. One honest participant is enough for soundness.
Limits and risks
- Anonymity set. Privacy is only as large as the crowd. Each token pool is its own set, and the clean set is smaller than the whole pool. The app warns when a set is small.
- Timing and amounts. Unique amounts and fast round trips can be matched by anyone. Use the habits in Withdraw.
- Issuer freezes. Tokens like USDC can be frozen by their issuer, including inside the vault.
- ASP trust. A wrongly declined deposit can still ragequit, but loses privacy. A wrongly approved one weakens the set for everyone.
- Smart contract risk. Audits reduce risk, they do not remove it. Deposit caps limit exposure early on.
- Regulation. Rules on privacy tools differ by country. Check yours.
SDK
@canonex/sdk handles keys, note sync, in-browser proving and relayer calls. @canonex/crypto exposes the raw primitives.
import { Canonex } from '@canonex/sdk'; const cnx = await Canonex.connect({ cluster: 'mainnet-beta', wallet }); await cnx.unlock(passphrase); // shield 10 SOL const dep = await cnx.shield({ mint: 'SOL', amount: 10_000_000_000n }); // wait until the ASP includes the deposit label await cnx.waitForApproval(dep.label, { timeoutMs: 3_600_000 }); // private send to a shielded address await cnx.send({ to: 'cnx1q…', mint: 'SOL', amount: 2_000_000_000n }); // withdraw to a fresh one-time address const to = cnx.nextOneTimeAddress(); await cnx.withdraw({ mint: 'SOL', amount: 5_000_000_000n, to, asp: 'default' });
Relayer and indexer API
Plain HTTPS, no accounts. Relayers do not log IP addresses or request bodies. Anyone can run a relayer by staking $CNX.
| Endpoint | Returns |
|---|---|
| GET /v1/info | Relayer address, stake, supported mints |
| GET /v1/quote | Gas fee for an action and mint |
| POST /v1/relay | Submits a proof, returns the signature |
| GET /v1/swap/quote | Jupiter route and minimum output |
| GET /v1/tree/state | Commitments since an index, current root |
| GET /v1/tree/asp | Association set leaves and current root |
| GET /v1/nullifiers | Spent nullifier hashes since a slot |
| GET /v1/label/:label | Pending, approved or declined |
On-chain program
Program ID: [PROGRAM ID]. Written in Rust with Anchor. Circuits in Circom.
| Instruction | What it does |
|---|---|
| deposit | Takes tokens, assigns a label, appends a commitment |
| transact | Private send or withdraw: verifies proof, records nullifiers, appends outputs |
| ragequit | Returns a pending or declined deposit to its origin |
| swap_prepare | Spends a note into a bound escrow |
| swap_execute | Runs the Jupiter route, mints the output note |
| swap_refund | Returns escrowed input if the minimum was not met |
| update_asp_root | ASP signer publishes a new association set root |
| register_asset | Opens a pool for a new mint (multisig) |
| set_* / accept_admin | Governance, behind the 72h timelock |
FAQ
Is Canonex a mixer?
It uses the same cryptography, but not the same rules. A mixer hides everyone together. Canonex lets you prove your funds come from a set that excludes illicit deposits, without revealing which deposit was yours.
Can the team or the ASP freeze my funds?
No. The ASP can only leave a deposit out of the clean set. Ragequit back to your original address always works, and the admin cannot pause withdrawals.
Who decides what is clean?
The ASP applies published, versioned rules. $CNX holders vote on the lists it uses, and later versions let you choose between several association sets.
What if a relayer goes offline?
Use another relayer, or submit the proof yourself from any funded wallet. That wallet becomes visible as the fee payer, so pick one with no link to you.
Is it fully anonymous?
No system is. Privacy depends on the size of the set, on timing and on amounts. Canonex removes the on-chain link; your habits decide how hard the rest is to guess.
I lost my recovery key.
If your browser still has the vault, export a new recovery key from settings right away. If both are gone, the funds cannot be recovered.
How is this related to Privacy Pools on Ethereum?
Canonex implements the Privacy Pools model described in the 2023 paper by Buterin, Illum, Nadler, Schär and Soleimani, adapted to Solana with private sends and swaps inside the pool. It is an independent project.
Glossary
| Term | Meaning |
|---|---|
| Note | A private balance unit: value, token, label, secret. |
| Commitment | Hash of a note, stored in the state tree. |
| Nullifier hash | Published when a note is spent. Prevents double spends without revealing the note. |
| Label | Deposit identifier carried by every note descended from it. |
| Association set | Merkle tree of approved labels. Its root lives on-chain. |
| ASP | Association Set Provider. Screens deposits and publishes the set. |
| Ragequit | Public exit of a deposit back to its origin address. |
| Relayer | Submits proofs and pays gas. Cannot change a transaction. |
| Anonymity set | The crowd your note hides among. |
| Viewing key | Read-only key that decrypts your notes. |