Alle Docs
Getting started·Aktualisiert 2026-05-26

Architecture

The four subsystems that make SolMask work, what each one knows, and the trust boundaries between them.

SolMask is split across four subsystems. The split is not incidental — each one has a different trust posture, and the boundaries are where the privacy guarantees are actually enforced.

The four subsystems

SubsystemRoleTrust posture
Anchor programOn-chain state, verifier, fee math, banlistTrustless (verifier code is on-chain and reproducible)
ZK circuitsBrowser-side proof generationTrustless once compiled (witness never leaves your machine)
RelayerPays gas for withdraws, dedupes nullifiers, queues jobsTrusted only with: your IP, your nullifier, the recipient address. Cannot steal funds.
Web + AdminUI, note storage, address-risk pre-check, admin toolsTrusted with: your wallet connection, your note plaintext in the browser session

The Anchor program

The program is the only one of the four where the rules are enforced rather than merely followed. Its instructions fall into three groups:

  • Admin-only configuration — initialize config, rotate admin, set the fee destination, configure the risk-screening endpoint, pause/unpause. All gated by an admin constraint against the singleton config account.
  • Pool setup — per-asset pool initialization and sweep-threshold configuration, admin-only.
  • User-facing — deposit, withdraw, and fee sweep.

State is a singleton config, one pool per asset (each with a shielded vault and a fee vault), per-pool Merkle-tree state and a recent-root history, one nullifier record per spent note, and one ban record per banned wallet.

The program is the trust root. Anything it accepts is by definition correct under the protocol's rules. Anything else — the relayer's idea of which nullifiers have been spent, the web client's idea of how much is in a note — is a hint at best.

The ZK circuits

The withdraw circuit is compiled to R1CS, then through a Groth16 phase-2 ceremony into a proving key and a verification key. The proving key is served to your browser; the verification key is embedded in the on-chain verifier. The two MUST come from the same ceremony — see Trusted setup.

Proof generation runs entirely in your browser via snarkjs+wasm. The circuit takes your note's private secrets plus a public-input vector (root, nullifier_hash, recipient, fee_recipient, current_slot, mint, amount_to_recipient, change_commitment) and produces a ~200-byte proof. The proof binds every public input — including the recipient — so a relayer that tries to redirect funds invalidates the proof.

The relayer

The relayer is an HTTP + queue service that accepts withdraw jobs and submits them on-chain. It pays the protocol fee (config.withdraw_fee_lamports, 0.003 SOL by default and admin-tunable without a redeploy) up front, then recoups exactly that fee by keeping a small tip slice — denominated in the deposit asset — out of the released amount_to_recipient before forwarding to the recipient. The Solana network gas for the transaction is absorbed by the relayer, not reimbursed. It runs an address-risk pre-check on the recipient and rejects high-risk addresses before broadcasting; it also dedupes nullifiers on the way in so a doubly-submitted proof from a confused client doesn't waste two on-chain attempts.

The relayer cannot steal: the proof binds the recipient address. It cannot censor in a strong sense either, because users who don't want to use it can self-relay (see Self-relay guide). What it can do is observe your IP and the moment you withdrew — the IP-correlation trade-off.

The web + admin apps

The web app is the user-facing surface: wallet connect, deposit form, note storage, withdraw flow, swap UI, blog/docs. The admin app is operational: it lets the configured admin pause/unpause, manage the banlist, sweep fees, and inspect pool state.

Note secrets — secret, nullifier_secret, amount, unlock_slot — never leave your browser. They are not random-and-stored; they are derived from your wallet. Connecting and signing SolMask's fixed key-derivation message yields a deterministic per-wallet seed (sha256 of the ed25519 signature), and every note's secrets are HKDF-expanded from that seed. The proof is generated locally and submitted as opaque bytes.

Because the derivation is deterministic, the same wallet reproduces the same secrets on any device — there is no passphrase to choose and no note file to back up. localStorage is only a convenience cache. The authoritative copy of each note is an encrypted recovery blob published on-chain (see Note recovery below), so a freshly opened browser recovers your full balance the moment you reconnect the wallet. This stays non-custodial: only your wallet can derive the seed, so only you can decrypt or spend. Lose access to the wallet itself and the notes are gone — SolMask holds nothing that could recover them.

Note recovery

A note's spendability depends only on its four secrets, and those secrets are reproducible from your wallet. To make a note discoverable on a device that has never held it, the deposit also publishes an encrypted recovery blob:

  • At deposit time the web app encrypts { leaf_index, amount, unlock_slot } to a key only your wallet can derive (AES-GCM under an HKDF-expanded view key). The ciphertext is emitted on-chain as DepositEvent.encrypted_note, capped at MAX_ENCRYPTED_NOTE_LEN.
  • A partial withdraw does the same for its change note via WithdrawEvent.encrypted_change_note (empty when the withdraw leaves no change).

To recover, the client re-derives the wallet seed, fetches every recovery blob for a pool from the relayer's indexer, and trial-decrypts each one. Only the blobs encrypted to your wallet decrypt successfully; for each hit the client re-derives the note's secrets, confirms the commitment matches the on-chain leaf, and checks the nullifier set to mark the note spent or unspent. The relayer serves the blobs but cannot read them, so it never learns which notes are yours. The whole scan is client-side and idempotent — it, not localStorage, is the source of truth for your balance.

Sequence: a deposit

User wallet                       Web app                          Anchor program
    |                                |                                    |
    |  --- click "Deposit 1 SOL" --->|                                    |
    |                                |  derive note secrets from wallet   |
    |                                |  commitment = Poseidon(s, n, a, u) |
    |                                |  encrypt recovery blob (wallet key)|
    |                                |                                    |
    |                                |  --- build & request signature --->|
    |  <--- sign deposit ix ---------|                                    |
    |  ----- broadcast tx --------------------------------------->        |
    |                                |                          program:
    |                                |                          - check banlist
    |                                |                          - full amount → shielded vault (deposit is free)
    |                                |                          - append commitment
    |                                |                          - push new root
    |                                |                          - emit deposit event
    |                                |                            (incl. encrypted_note)
    |                                | <-- deposit event (leaf index, root) --

Sequence: a withdraw

User wallet                Web app             Relayer              Anchor program
    |                         |                    |                       |
    |  --- click Withdraw --->|                    |                       |
    |                         |  generate proof    |                       |
    |                         |  (snarkjs/wasm)    |                       |
    |                         |                    |                       |
    |                         |  -- POST /withdraw ->|                     |
    |                         |    {proof, PIs}    |                       |
    |                         |                    |  recipient risk-check |
    |                         |                    |  + dedup              |
    |                         |                    |                       |
    |                         |                    |  --- submit tx ------>|
    |                         |                    |                  program:
    |                         |                    |                  - verify proof
    |                         |                    |                  - check root in history
    |                         |                    |                  - init nullifier record
    |                         |                    |                  - release tokens
    |                         |                    |                  - protocol fee → fee destination
    |                         |                    |                  - emit withdraw event
    |                         |                    |                    (incl. encrypted_change_note)
    |                         | <-- 200 OK + sig --|                       |

Trust boundaries summarized

  • Between user wallet and web app: the web app holds note plaintext during the session.
  • Between web app and relayer: the relayer sees the public-input vector, the proof bytes, the recipient, and your IP. It never sees the note secrets.
  • Between relayer and program: the program enforces the rules. The relayer can submit malformed payloads — they get rejected.
  • Between admin app and program: admin instructions all gate on the admin signer. The admin can pause, ban, sweep, and set the fee destination. It cannot view note plaintext and cannot retroactively spend a note it didn't create.

The architecture is deliberately top-heavy on the program. Everything that matters for funds and for privacy is enforced there. The off-chain services exist for UX and economics, not for safety.

Architecture · SolMask