SolMask ships with a hosted relayer at relayer.solmask.xyz; the current endpoint is announced in the SolMask Discord. It is the default, it is the easiest path, and it is what 99% of users will use. But the protocol does not require it. Any signer with enough SOL to cover gas plus the 0.003 SOL protocol fee can submit a withdraw transaction directly.
This page covers when to self-relay, what you give up, and how to do it.
When self-relay makes sense
Use the hosted relayer if:
- You do not want to expose any of your wallets to RPC providers — the relayer's RPC connection sees the IP of the relayer server, not yours.
- You do not want to fund your recipient wallet with any SOL up-front. The relayer pays gas and the 0.003 SOL protocol fee; the protocol fee is recouped from the tip portion of the withdraw quote, and the relayer absorbs the network gas itself.
Self-relay if:
- You are operating from an environment that does not trust the hosted relayer (regulatory restrictions, threat models where any third-party intermediary is unacceptable, or you want to verify the protocol works without us).
- You have a separate signer that already has SOL and does not need privacy for itself — a treasury operator, an automation key, a service account.
- You are integrating SolMask into another product and want to control submission timing yourself.
What you give up
| Property | Hosted relayer | Self-relay |
|---|---|---|
| Who pays gas | Relayer (absorbs the network gas cost) | Your signer |
| Who pays the 0.003 SOL fee | Relayer (forwards to the fee destination) | Your signer (directly to the fee destination) |
| Whose IP touches the RPC | Relayer's | Yours |
| Recipient must hold SOL beforehand | No (USDC withdraws auto-convert) | Yes, for non-SOL withdraws — or you bundle a Jupiter swap manually |
| Address-risk recipient pre-check | Yes (before broadcast) | No (your responsibility) |
| Tx fits in one transaction | Always (relayer uses ALT) | Maybe; complex withdraws may exceed the 1232-byte limit without an ALT |
The headline trade-off is the IP correlation. Even with a perfect zero-knowledge proof, a passive observer who can correlate your destination wallet's first appearance with your IP touching api.mainnet-beta.solana.com has the same information as if the chain had logged it explicitly. The hosted relayer breaks that correlation because the relayer's IP is what touches the RPC, and many users share the relayer.
If you are running a Tor circuit, a residential VPN per session, or your own private RPC node, the IP-correlation concern shrinks. If you are submitting from your normal home or office connection, the hosted relayer is materially safer for your privacy.
Custody and the recipient binding
The relayer is liveness-trusted, not asset-custodial in the general case, but the precise guarantee depends on the withdraw shape:
- Plain same-asset withdraw, no swap, no in-asset tip (e.g. a SOL→SOL self-relay, or any same-mint withdraw the client marks
enforce_recipient): the proof binds the payout to your recipient on-chain, so the program pays your recipient directly and the relayer never holds the funds — it only fronts gas. - Cross-asset (Jupiter) swaps, withdraws that recoup the fee as an in-asset tip, and native-SOL unwraps: the released funds pass through a relayer-controlled escrow for the moment it takes to swap or forward them. In these cases the hosted relayer is trusted for liveness — it could, in principle, fail to forward. It can never release more than the proof's amount, and self-relay removes this trust entirely. If you do not want to trust the hosted relayer for a swap/tipped withdraw, self-relay and run the swap yourself.
The withdraw instruction's account list
The withdraw instruction's accounts:
pub struct Withdraw<'info> {
pub config: Account<'info, Config>,
pub mint: InterfaceAccount<'info, Mint>,
pub pool: Account<'info, Pool>,
pub vault: InterfaceAccount<'info, TokenAccount>,
pub merkle_frontier: Account<'info, MerkleFrontier>,
pub root_history: Account<'info, RootHistory>,
pub nullifier_record: Account<'info, NullifierRecord>, // init'd here
pub fee_recipient_token_account: InterfaceAccount<'info, TokenAccount>,
pub fee_collector: AccountInfo<'info>,
pub relayer: Signer<'info>, // <-- this is YOU when self-relaying
pub token_program: Interface<'info, TokenInterface>,
pub system_program: Program<'info, System>,
}
The relayer account is the signer who pays for the init of the nullifier PDA, pays compute units, and pays the 0.003 SOL protocol fee. In a self-relay flow, this is your wallet.
Step-by-step procedure
The web app's withdraw client is the reference implementation. The high-level shape:
1. Have the note ready
You need the four pre-images of your commitment: secret, nullifier_secret, amount, unlock_slot. These are derived deterministically from your wallet — connect the same wallet you deposited with and SolMask re-derives them locally from the deposit's on-chain recovery blob (no passphrase, no note file). Export the decoded values from the web app's note view and load them into your script.
2. Fetch the current Merkle state
import { Connection, PublicKey } from '@solana/web3.js';
import { Program } from '@coral-xyz/anchor';
const conn = new Connection(RPC_URL);
const program = /* anchor Program built from the IDL + provider */;
const poolPda = derivePool(MINT);
const frontier = await program.account.merkleFrontier.fetch(deriveFrontier(poolPda));
// frontier.frontier is the array of right-most filled nodes per level
// frontier.next_leaf_index is your insertion-slot count
// Fetch the inclusion path for your specific leaf via the relayer's read API,
// or rebuild it yourself by replaying all DepositEvents from the program.
const path = await fetchInclusionPath(yourLeafIndex);
3. Generate the proof in the browser (or your script)
import { groth16 } from 'snarkjs';
const input = {
// public
root, nullifier_hash, recipient, fee_recipient,
current_slot, mint, amount_to_recipient, change_commitment,
// private
secret, nullifier_secret, amount_total, unlock_slot,
merkle_path: path.elements, merkle_path_indices: path.indices,
change_secret, change_nullifier_secret, change_unlock_slot,
};
const { proof, publicSignals } = await groth16.fullProve(
input,
'/circuits/withdraw.wasm',
'/circuits/withdraw_final.zkey',
);
current_slot must be the slot you expect the transaction to land in. Use a recent slot and ensure the tx lands within the accepted drift window — SlotMismatch rejects anything outside it. The standard practice is: fetch the latest slot via connection.getSlot(), build for that slot, and submit immediately. If the tx fails with SlotMismatch, rebuild for the new latest slot.
4. Build and sign the transaction
import { TransactionInstruction, TransactionMessage, VersionedTransaction } from '@solana/web3.js';
const ix = await program.methods
.withdraw(
proofA, // [u8; 64]
proofB, // [u8; 128]
proofC, // [u8; 64]
{ root, nullifierHash, recipient, feeRecipient, currentSlot, mint,
amountToRecipient, changeCommitment },
)
.accountsStrict({
config: configPda,
mint: MINT,
pool: poolPda,
vault: poolVault,
merkleFrontier: frontierPda,
rootHistory: rootHistoryPda,
nullifierRecord: deriveNullifier(poolPda, nullifierHash),
feeRecipientTokenAccount: yourRecipientAta,
feeCollector: config.feeCollector,
relayer: yourWallet.publicKey, // YOU
tokenProgram: TOKEN_PROGRAM_ID,
systemProgram: SystemProgram.programId,
})
.instruction();
// Use the deployed Address Lookup Table to fit into a v0 transaction
const alt = await conn.getAddressLookupTable(SOLMASK_ALT).then((r) => r.value!);
const blockhash = (await conn.getLatestBlockhash()).blockhash;
const msg = new TransactionMessage({
payerKey: yourWallet.publicKey,
recentBlockhash: blockhash,
instructions: [ix],
}).compileToV0Message([alt]);
const tx = new VersionedTransaction(msg);
tx.sign([yourWallet]);
The Address Lookup Table is created at deploy time and includes the program ID, the token program, the system program, and the well-known pool/vault PDAs. Without it, complex withdraws (especially with a Jupiter swap appended) overflow the 1232-byte transaction size limit.
5. Submit and confirm
const sig = await conn.sendRawTransaction(tx.serialize(), {
skipPreflight: false,
maxRetries: 3,
});
await conn.confirmTransaction(sig, 'confirmed');
On success: the protocol fee (3,000,000 lamports = 0.003 SOL) is debited from your wallet and transferred to the fee destination. The amount_to_recipient is debited from the pool's vault and credited to the fee-recipient token account. The nullifier record is created; the change commitment, if any, is appended to the tree.
On SlotMismatch: rebuild for the new latest slot. On UnknownRoot: rebuild the proof against the current recent-root window. On AccountAlreadyInitialized (for the nullifier): your note was already spent.
What you do NOT need
- You do not need to register as a relayer with SolMask. There is no operator allowlist on
withdraw; therelayeraccount is unconstrained beyond being aSigner. - You do not need any approval from SolMask ops. The protocol is permissionless on the withdraw side as long as the proof verifies.
- You do not need to use the hosted relayer's quote API. The on-chain instruction is the source of truth; the quote is a UX layer on top.
What you DO need is to construct a valid proof against a real note, and to budget ~0.0035 SOL per withdraw (the 0.003 SOL protocol fee plus a few hundred thousand lamports of tx fees + rent for the new nullifier PDA, which the relayer signer pays).