Changelog

Protocol, web, and relayer releases for SolMask. Versioned, dated, and grouped by month.

Atom

May 2026

  • Protocolv0.4.0

    Free deposits — the protocol fee moves to withdrawal

    Deposits are now free: the full amount enters the pool. The 0.23% protocol fee moved to withdraw time as an admin-tunable percentage fee, charged in the withdrawn asset and bound inside the withdraw zero-knowledge proof. The flat 0.003 SOL withdraw fee stays. Both rates are tunable on-chain without a redeploy.

    Deposits are free

    • The deposit instruction no longer takes a fee. The full deposited amount is credited to the shielded vault and bound into your note commitment (the C1 deposit-binding proof now pins the full amount, not a post-fee value). The per-pool token fee vault is no longer touched on deposit.

    The percentage fee moved to withdrawal

    • A new global Config.withdraw_fee_bps (default 0, recommended 23 bps = 0.23%, capped at 100 bps) is charged at withdraw time in the withdrawn asset and accrues to the pool's token fee vault — reusing the existing permissionless sweep path.
    • The fee is bound inside the withdraw circuit: JoinSplit now proves Σ inputs = Σ outputs + fee_amount + change, with fee_amount a new public input. The on-chain handler recomputes floor(total_out * withdraw_fee_bps / 10_000) from the configured rate and rejects any proof whose bound fee_amount doesn't match (FeeMismatch). The relayer cannot tamper with the fee.
    • This required a withdraw-circuit recompile, a fresh trusted-setup phase-2 ceremony, and a re-embedded verifying key. The public-input count went from 13 to 14, updated in lockstep across the circuit, the on-chain WithdrawPublicInputs, the relayer request schema, and the web client.

    Both withdraw fees are admin-tunable

    • Config.withdraw_fee_bps is settable via the new set_withdraw_fee_bps instruction; the flat Config.withdraw_fee_lamports remains settable via set_withdraw_fee. Both are capped on-chain (1.00% and 0.1 SOL respectively) and adjustable without a program redeploy. The admin dashboard's Fees page now reads both live rates and exposes inputs to change them.
    • CONFIG_VERSION is now 4. Pre-v4 config accounts are realloc'd and zero-initialized (fee-free) on the first set_withdraw_fee_bps (or set_withdraw_fee) call; turn on the recommended rate with set-withdraw-fee-bps.ts --bps 23 as an explicit launch step.

    What this means for you

    • Funding the pool now costs nothing — you only pay when value actually leaves the shielded set. A round-trip costs about the same as before, just shifted to the exit. See /docs/fees for worked examples.
  • Protocolv0.3.0

    Wallet-derived note recovery, admin-tunable withdraw fee, and a slot-drift tolerance window

    Notes are now derived from your wallet and recoverable from on-chain encrypted blobs on any device — no passphrase, no note file. The withdraw fee moved from a hardcoded constant to an admin-tunable Config field, recouped via a deposit-asset tip. The withdraw slot check is now a bounded backward window instead of an exact match.

    Wallet-derived notes and cross-device recovery

    • Note secrets are now derived deterministically from the connected wallet: the wallet signs a fixed key-derivation message, the signature is hashed (sha256) into a per-wallet seed, and each note's secrets are HKDF-expanded from that seed. There is no passphrase to choose and no note file to download or back up.
    • Deposits publish a wallet-encrypted recovery blob on-chain — DepositEvent.encrypted_note, with WithdrawEvent.encrypted_change_note carrying the change note for partial withdraws (both capped at MAX_ENCRYPTED_NOTE_LEN). Reconnecting the same wallet on any device re-scans the pool, trial-decrypts the blobs that belong to it, and rebuilds your full balance. localStorage is now only a convenience cache, not the source of authority.
    • The protocol 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.

    Admin-tunable withdraw fee

    • The withdraw fee moved from a compile-time constant to Config.withdraw_fee_lamports (default 3_000_000 = 0.003 SOL; bounded at 100_000_000 = 0.1 SOL). It is settable via the set_withdraw_fee instruction without a program redeploy. CONFIG_VERSION is now 2; pre-v2 config accounts are realloc'd and initialized on the first set_withdraw_fee call.
    • The relayer pays that SOL fee to the fee collector up front and recoups exactly it by keeping a small tip slice — denominated in the deposit asset — out of the released amount before forwarding to the recipient. The Solana network gas is absorbed by the relayer, not reimbursed by the fee.

    Withdraw slot tolerance window

    • The withdraw handler no longer requires current_slot to exactly equal the runtime slot. It now enforces current_slot <= clock.slot and clock.slot - current_slot <= MAX_SLOT_DRIFT (150 slots, ~60s at 400ms/slot). This absorbs the proof-gen → relayer-submission latency that made an exact match unachievable.
    • A future-dated slot is still rejected, so the privacy timelock cannot be escaped; a stale proof is rejected with SlotMismatch once it drifts past the window.
  • Webv0.2.0

    Fee model updated to 23 bps / 0.003 SOL; Send UX rewritten in bigint math

    Deposit fee is now 23 bps in the deposit asset and the per-recipient withdraw fee is 0.003 SOL. The Send flow now does all amount arithmetic in bigint to remove float drift.

    Fees

    • Deposit fee constant is DEPOSIT_FEE_BPS = 23n (0.23%), applied to the deposit amount in the deposit asset itself. The note is minted for amount - fee.
    • Withdraw fee is a flat 0.003 SOL per recipient, charged at withdraw time by the protocol. The relayer signs and submits the withdraw transaction; it does not subsidise the fee. The 0.003 SOL fee is what funds the on-chain compute + the relayer's submission cost.

    Send flow rewrite

    • Every amount entered in the Send tab is now parsed into a bigint (base units) immediately and stays a bigint for the rest of the lifecycle — equality checks, splitting, max calculations, and fee deductions. No Number round-trips, no parseFloat anywhere in the hot path.
    • "Split evenly" now divides the source amount across N recipients in base units, with the rounding remainder added to the last row so the displayed total matches the source amount exactly.
    • Per-row MAX button fills the row with the maximum that still leaves enough to cover the 0.003 SOL withdraw fee per remaining row. Previously the MAX button reused the deposit-tab logic and would over-fill on multi-recipient sends.

    UX moves

    • The privacy-delay slider (unlock-slot picker) moved from the Send tab into the Deposit tab where it belongs — the unlock slot is locked at deposit time, not at send time. Send no longer shows a delay control.

    IDL

    • IDL regenerated from the current on-chain program. The only public-API surface that changed is the Pool account: a new vault_balance field exposes the unencrypted vault balance for indexer / pool-stats consumers.
  • Relayerv0.1.0

    Withdraw transactions now use a static Address Lookup Table

    Same-mint SOL withdraws no longer exceed Solana's 1232-byte raw tx limit.

    What changed

    • The relayer now packs every withdraw inside a v0 transaction backed by a static Address Lookup Table (ALT). Same-mint, multi-recipient withdraws were tipping past Solana's 1232-byte raw transaction limit because every recipient pulled in the program ID, the verifier program, the vault PDA, and the system program as fresh account keys. With the ALT those addresses are referenced by 1-byte indices instead of 32-byte pubkeys.
    • The ALT is created lazily on first use and cached in the relayer's local state. On startup the relayer checks whether its cached ALT account still exists on-chain (it can be closed by anyone after the deactivation window) and recreates it if not.

    Nullifier-replay handling

    • If a submitted withdraw transaction lands but the relayer's RPC times out before getting the signature back, the next retry would previously fail with NullifierAlreadyUsed and surface as a generic 500 to the caller. The retry path now recognises the duplicate-nullifier error specifically, looks up the existing successful signature for that nullifier from the indexer, and returns it to the caller as if the original submission had succeeded.

    API surface

    • GET /pools now returns vault_balance for each pool, mirroring the new IDL field. Consumers can compute the anonymity-set size as commitment_count and the pool's redeemable balance as vault_balance without making a separate getTokenAccountBalance call.
Changelog · SolMask