Dark mode
Dark mode funds an agent without anything onchain linking your wallet to it. You deposit a fixed amount of USDC into a shielded pool and keep a note. Later, the note pays a fresh stealth wallet made in your browser, through a withdraw that a zero-knowledge proof authorises and the keeper relays. The stealth wallet then gives an arm the usual way. Onchain, the withdraw shows that some note of the pool was spent, not which deposit it came from.
How it works
deposit your wallet ── 1, 10 or 100 USDC ─▶ pool vault (a PDA of the program)
note = (nullifier, secret), two random numbers made in your browser, shown once
commitment = Poseidon(nullifier, secret) is added to the pool's Merkle tree (depth 20)
fund your browser: new stealth key, then a Groth16 proof that it knows a note in the tree
public inputs: root, Poseidon(nullifier), stealth wallet, relayer, relayer fee
keeper ── one transaction, fee payer = keeper ─▶ Solana:
create the stealth wallet's USDC account, send it 0.00278952 SOL,
withdraw: proof checked onchain (alt_bn128), nullifier recorded, USDC paid out
stealth wallet ── gives an arm the usual way (its own key, its own SOL) ─▶ agent
after Ink returns the USDC to the stealth wallet; "deposit back" turns it into new notesThe stealth wallet's SOL comes from the keeper inside the relayed withdraw, never from your wallet: one arm's rent, the wallet's own rent-exempt minimum (a Solana account that holds SOL must keep it) and 0.0001 SOL for its own network fees (giving the arm, Ink, depositing back). The note never leaves your browser; the keeper receives the proof and its public inputs only.
What it hides
- The link between the wallet that deposited and the stealth wallet, its arms, its session keys and its agents.
- Which deposit a withdraw spends: every unspent note of the same pool fits the proof equally.
What it does not hide
- Deposits are public: anyone can see that your wallet put 1, 10 or 100 USDC into a pool, and when.
- The agents' payments to APIs stay public: arm, payee, amount and memo of every payment are Solana transactions.
- Privacy grows with the number of deposits in each pool. In a pool with few deposits, or right after your own deposit, timing alone can point to you. Wait for other deposits, and do not withdraw the same amounts you deposited at the same moments.
- The keeper sees the IP address of every request it relays, as the site sees the IP address of every page and API call.
- What you do later can link you again: sending the stealth wallet's USDC to your wallet, or giving the same agent arms from both.
- Stealth keys are kept in this browser's local storage; anyone with access to the browser profile can read them.
Fees
| Deposit | No fee. Your wallet pays the network fee. |
| Dark fee | 0.5% of the withdrawn amount in USDC, paid by the program to the relay fee account 7ar53VLy3cBgywMrP6p5bvn7DMryQ8jEGCdv8Sxrzsf2. It joins the week's relay fees for the Monday buy and burn of $ALLOW. No holder rate in dark mode: checking a balance would link the wallets. |
| Relayer fee | In USDC, taken from the withdrawn amount, to the keeper. It covers the keeper's SOL costs for one withdraw: the stealth wallet's USDC account rent (0.00148844 SOL), the nullifier record's rent (0.00069088 SOL), the SOL sent to the stealth wallet (0.00278952 SOL: an arm's 0.00203928, its own rent-exempt minimum of 0.00065024 and 0.0001 for fees) and the withdraw's own network fee (0.000013 SOL), 0.00498184 SOL in all at today's rent, priced at Jupiter's SOL price plus 10% and rounded up to 0.001 USDC. At 100 USD per SOL that is 0.549 USDC. The app shows the exact figure before you fund. |
The relayer fee does not depend on the pool, so the 1 USDC pool keeps much less of a note than the 10 and 100 USDC pools. Of the rents, the arm's comes back to the stealth wallet when the arm closes; the USDC account's and the wallet's own stay with the stealth wallet.
The program
| Program | 4cm1XnB9xinbqAbRyg97pmySvrK856QGUpGrbAYicC6S (Anchor, source in the repository under dark/program) |
| Status | deployed on Solana (read from the chain now) |
| Upgrade authority | 2dYJ4HeHejHhp6XPpvJnmyvZRCNjW4pU9KQsbDuU4Rw9 |
| Pools | 1 USDC 67zWhLLU5TaWy6vBRF5bZVBfAbTTqCzTjFq6c9mGvRNU10 USDC 8yXjHpa4veozXqSK1FCSNuhc28uC44LiDS6gMunEdv5n100 USDC 8L1wjjqnwkYWcL4U39VuGs6pergspy4vHCEEE16C6zRR |
| Vaults | USDC accounts owned by their pool's PDA, for example 2b9BrRtbYGdPDBUS95YtBhVjMgcWpQLNoBu6Y6F4BQSn for 1 USDC |
- No instruction moves pool USDC without a valid proof of an unspent note. There is no admin instruction, no pause and no fee setting: the denominations, the fee account and the 0.5% are constants of the program.
- The upgrade authority is a key the team holds offline; it is never on the server. It can replace the program's code, and new code could move pool funds differently. Until it is revoked, using dark mode means trusting that key.
- A note can be spent once: the withdraw creates a record at a PDA of (pool, Poseidon(nullifier)), and a second one fails.
- The proof binds the stealth wallet, the relayer and the relayer fee: a relayer that changes any of them makes the proof fail.
- The pool keeps its last 30 roots, so a proof made a few deposits ago still verifies.
- A withdraw pays the dark fee into the relay fee account, so it needs that account to exist; it is a USDC account of the keeper, which could close it only empty. USDC itself can be frozen by its issuer, in the vaults as anywhere else.
Instructions and accounts
initialize(denomination) once per pool, anyone may call it; denomination must be 1, 10 or 100 USDC
deposit(commitment) moves exactly the denomination into the vault, inserts the commitment
withdraw(a, b, c, root, nullifierHash, fee)
relayer signs; verifies the Groth16 proof with the alt_bn128 syscalls;
pays denomination - 0.5% - fee to the recipient's USDC account,
fee to the relayer, 0.5% to the relay fee account
pool PDA ["pool", denomination as u64 little-endian]
vault PDA ["vault", pool] USDC account, authority = pool
nullifier PDA ["nullifier", pool, nullifierHash]
recipient sha256(address) with its first byte zeroed, as a circuit input (same for the relayer)The Merkle tree uses Poseidon with circomlib's parameters on BN254, the same as the Solana Poseidon syscall. Both syscalls (Poseidon and alt_bn128) were checked active on Solana before the program relied on them. The verifying key is compiled into the program.
The circuit and its trusted setup
One circuit, withdraw.circom: 5,316 constraints, depth 20, public inputs root, nullifierHash, recipient, relayer and fee. Phase 1 is the PSE perpetual powers of tau (ppot_0080_13.ptau, 80 public contributions), downloaded from its public address. Phase 2 has two contributions made with fresh randomness that was never written anywhere, then a public beacon: the finalized Solana blockhash HGYZGNf1frQiq3QkmTRrP1wQTj53rWvJeRbdnX7ici6B (slot 464530461), applied with 2^10 iterations. The final key was verified against the circuit and phase 1. Both phase 2 contributions were made by the team, so the phase 2 secret is as safe as the team's word that it discarded it; the transcript is in the repository under dark/ceremony.
| Phase 1 file | ppot_0080_13.ptau sha256 ccee28086e4b81d81a6e16fdee054d1dbd5276362e2662d4205d31de45cb930f |
| Circuit hash | f7b991fdb2f7cbb086acd7b87fa9e2f060afa342ad0fbc9f77023b34c3cf568d2a38644eeb2574f690677044920c75fa1ff9761e499a308f543b1893a4c78911 |
| Contribution 1 | allowance-dark contribution 1: 82003774ceb8a02fac17dc3f8243636fde553db49796dd6d6a46c626fe26d0d40d74062d82ba6f52bb7ffdef51121778a54b103be7900df41f083b552218ff2c |
| Contribution 2 | allowance-dark contribution 2: d8873e4664b43394311777a4d5765fbce196fd1ab93485b063f9bdccde6c5b23402fd394e8a2df7cd0336a128b10407573c8fc5ac0ab38c826d188d89c8b1907 |
| Contribution 3 | Solana blockhash HGYZGNf1frQiq3QkmTRrP1wQTj53rWvJeRbdnX7ici6B: a7d065e1f3ca1a9d49f16cd87c73261f5974a9eca7d3b19e43fda3705f8916498332bbf9e9d70d8e044ab37ff891f947d2faf8c722802b543aae56379ad70903 |
| Proving key | /zk/withdraw_final.zkey sha256 282c4232f2f77c06751b63c384de5666fb258a0e42fe9bbc0ded652c6aeb6dba |
| Circuit wasm | /zk/withdraw.wasm sha256 bf924e2302d661fb8f1fe5e4d25e86c5e598f0133d33397704665303ff575f05 |
| Verifying key | /zk/verification_key.json sha256 da83676cba2ffd3f769a5c83077d7d8118e167d8fccbc67a0d4167a053e35d3b |
npx snarkjs zkey verify withdraw.r1cs ppot_0080_13.ptau withdraw_final.zkey # lists the contributions aboveAPI
GET https://www.allowanceengine.com/api/dark/status program, upgrade authority, the three pools (deposits, USDC held)
GET https://www.allowanceengine.com/api/dark/tree?d=10 a pool's commitments in tree order and its recent roots
GET https://www.allowanceengine.com/api/dark/quote?d=10 relayer, relayer fee, dark fee, what the stealth wallet receives
POST https://www.allowanceengine.com/api/dark/tx { owner, notes: [{ denomination, commitment }] } unsigned deposit
POST https://www.allowanceengine.com/api/dark/relay { denomination, proof: { a, b, c }, root, nullifierHash, recipient, fee }The tree endpoint is a convenience: the browser rebuilds the tree, checks that its root is one of the pool's onchain roots, and only then proves. A wrong list cannot make a wrong withdraw pass, only fail.
Tests
The program's .so runs in a local Solana VM with real proofs from the ceremony's files: deposit, a relayed withdraw, a second spend of the same note (refused), a root the pool never had and a known root that is not the proven one (refused), a changed recipient, a fee raised by one unit, another relayer (all refused), a note of the 1 USDC pool presented to the 10 USDC pool (refused), a pool of another amount (refused), a fee that leaves nothing (refused), roots older than the last 30 (refused), a commitment outside the field (refused), and deposits back from a stealth wallet.