how it works
A private payment in KIRITE passes through three steps: deposit, wait, and withdraw. Each step strips a different layer of metadata from the transaction.
step 1: deposit
The user wraps the chosen denomination of SOL into WSOL on their own ATA, then submits a deposit instruction to the shield pool. Before broadcasting:
- The browser generates a random
nullifier_secretandblinding_factoras 32-byte field elements. - The browser computes a Poseidon commitment:
Poseidon(nullifier_secret, denomination, blinding_factor, leaf_index). - The deposit instruction is submitted with the commitment plus the existing WSOL ATA. The on-chain program inserts the commitment as a Merkle leaf and increments
next_leaf_index. - The note (nullifier secret + blinding factor + leaf index) is saved to the user's device. It never leaves.
The chain stores only the commitment hash. Without the pre-image, no observer can tell which deposit corresponds to which note. The pre-image stays on the depositor's device until withdraw time.
// client-side commitment (browser)
const ns = randomFieldBytes();
const bf = randomFieldBytes();
const commitment = await poseidon([
ns, denomination, bf, leafIndex
]);
// only the commitment hash goes on-chain
await program.deposit({ commitment });step 2: wait
Withdrawing immediately after depositing leaks timing. The miniapp recommends waiting at least 10 minutes; longer is better when traffic is light. The longer you wait, the more other deposits can join the pool, and the larger the anonymity set when you withdraw.
- Same denomination (0.01 / 0.05 / 0.1 / 1 / 10 SOL) deposits share an anonymity set.
- Pool capacity is 32,768 leaves on v2 (Merkle height 15).
- Practical privacy is bounded by the active anonymity set; sparse pools are weak even when capacity is large.
anonymity set bounds
// upper bound on chain-only deanonymization
P(link | withdraw) <= 1 / n
n = 10 → up to 10% per attempt
n = 100 → up to 1% per attempt
n = 1000 → up to 0.1% per attempt
n = 32768 → up to 0.003% per attempt (v2 cap)These are upper bounds. Combined timing + recipient-pattern analysis can narrow further, especially in light pools. See privacy math for details.
step 3: withdraw
When ready, the client generates a Groth16 membership proof in the browser and submits it through the relayer:
- The client fetches every leaf currently in the pool by replaying
DepositCommittedevents from the program log. - The client builds a Merkle path for its own leaf, computes
nullifier_hash = Poseidon(ns, leaf_index), and generates a Groth16 proof bound to the recipient stealth address. - The proof + public inputs (root, nullifier hash, denomination, recipient hash) are POSTed to the relayer.
- The relayer signs the on-chain withdraw, paying gas and per-nullifier rent. The on-chain verifier checks the proof and creates a nullifier-record PDA seeded by
nullifier_hash. A second use of the same nullifier collides on PDA init and reverts. - The output WSOL is unwrapped to native SOL and forwarded to the recipient's stealth address atomically in the same transaction.
end-to-end flow
what an observer sees
| metadata | without KIRITE | with KIRITE v1 |
|---|---|---|
| transaction amount | fully visible | fixed denomination (0.01 / 0.05 / 0.1 / 1 / 10 SOL) |
| sender address (deposit) | fully visible | visible at deposit time, decoupled from recipient |
| recipient address | fully visible | one-time stealth address |
| deposit ↔ withdraw link | deterministic | ZK-broken (Groth16 + Poseidon) |
| timing correlation | exact timestamps | weakened by traffic; not enforced on-chain |