All docs
Operations·Updated 2026-05-26

Self-relay guide

How to bypass SolMask's hosted relayer: build, sign, and submit a withdraw transaction with your own wallet. Trade-offs and step-by-step procedure.

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

PropertyHosted relayerSelf-relay
Who pays gasRelayer (absorbs the network gas cost)Your signer
Who pays the 0.003 SOL feeRelayer (forwards to the fee destination)Your signer (directly to the fee destination)
Whose IP touches the RPCRelayer'sYours
Recipient must hold SOL beforehandNo (USDC withdraws auto-convert)Yes, for non-SOL withdraws — or you bundle a Jupiter swap manually
Address-risk recipient pre-checkYes (before broadcast)No (your responsibility)
Tx fits in one transactionAlways (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; the relayer account is unconstrained beyond being a Signer.
  • 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).

Self-relay guide · SolMask