core protocol

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:

  1. The browser generates a random nullifier_secret and blinding_factor as 32-byte field elements.
  2. The browser computes a Poseidon commitment: Poseidon(nullifier_secret, denomination, blinding_factor, leaf_index).
  3. 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.
  4. The note (nullifier secret + blinding factor + leaf index) is saved to the user's device. It never leaves.
key invariant

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:

  1. The client fetches every leaf currently in the pool by replaying DepositCommitted events from the program log.
  2. 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.
  3. The proof + public inputs (root, nullifier hash, denomination, recipient hash) are POSTed to the relayer.
  4. 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.
  5. 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

01depositcommit fixed denomination to pool
02shield poolposeidon merkle anonymity set
03withdrawgroth16 proof to stealth address

what an observer sees

without kirite
amount: 14,000 USDC
sender: 7xK9..3fQ2
receiver: 9mR2..xK7P
link: deterministic
with kirite
amount: fixed denomination (uniform)
sender: 1 of n depositors
receiver: stealth address
link: broken
metadatawithout KIRITEwith KIRITE v1
transaction amountfully visiblefixed denomination (0.01 / 0.05 / 0.1 / 1 / 10 SOL)
sender address (deposit)fully visiblevisible at deposit time, decoupled from recipient
recipient addressfully visibleone-time stealth address
deposit ↔ withdraw linkdeterministicZK-broken (Groth16 + Poseidon)
timing correlationexact timestampsweakened by traffic; not enforced on-chain