Most privacy-protocol writing is about sending. Sending is the easy half — you control the timing, amount, and source wallet. Receiving is harder, because the address you publish to whoever is paying you becomes the anchor for everything that follows. If the recipient address has any prior on-chain history, the privacy you bought on the sender side is mostly wasted. If it doesn't, you have to make sure the very first transaction it sees — the SolMask withdraw — is also the first transaction the rest of the world sees. This post is about how to do that correctly: how to generate a fresh recipient wallet, why SolMask withdraws don't require it to hold any SOL, and what not to do afterwards.
What does "fresh" actually mean for a recipient address?
A fresh Solana address, for the purposes of a SolMask withdraw, is one that has never appeared as a signer or a writable account in any prior transaction. No prior deposits. No NFT mints. No airdrop claims. No DAO votes. No "test transaction" of 0.001 SOL from your main wallet. No staking history. No Jupiter swaps. Nothing.
The reason this matters is structural. SolMask's withdraw breaks the on-chain link between the depositor's funding wallet and the recipient address by spending a zero-knowledge proof against a Merkle root and revealing only a nullifier — nullifier_secret hashed against the leaf — that proves "one valid note was burned" without revealing which. The protocol cannot tell you which deposit funded which withdraw; neither can a chain analyst observing the pool from the outside. That guarantee is what you are paying 23 bps and 0.003 SOL for.
But the guarantee terminates at the moment the withdraw lands. From the next slot onward, the recipient address has a permanent, public on-chain footprint that starts with "received X from the SolMask withdraw vault." If that address already had a history — say it received 0.5 SOL from your treasury wallet last week — the chain analyst's job becomes trivial. They don't need to break the cryptography. They look at the address's pre-existing edges in the transaction graph and read your treasury wallet straight off the screen.
This is the most common deanonymisation mistake in shielded-pool usage. It's users handing over the answer for free because they reused a wallet they already had. The rule: one fresh address per private receive. Generated cleanly. Never funded from a wallet linked to you. Used once, or only for purposes intended to be linked together.
How do I generate a clean Solana receiving wallet?
There are three reasonable paths, with increasing operational separation and increasing operational cost. Pick the one that matches the sensitivity of the receive.
Path one: a new subaccount inside Phantom or Solflare. In Phantom, "Manage Accounts" → "Add / Connect Wallet" → "Create new wallet" generates a new keypair derived from the same seed phrase as your other Phantom accounts. Solflare is similar — "Add wallet" → "Create new wallet" derives a fresh BIP-44 index. Easiest option, genuinely fine for many cases. The new address has no on-chain history; from Solana's perspective it is brand new. The catch is that the wallet UI on your local device knows the two accounts share a seed, so if your device is ever compromised — malware, screen recording, a forensic image — an examiner can see the "fresh" address belongs to the same human as your daily-driver wallet. Local-device threat, not a chain-analysis one. Sufficient for keeping a contractor payment off your treasury's public balance; not sufficient against a forensic examiner.
Path two: a separate wallet app or browser profile. Install Solflare alongside your daily Phantom, or use a Chrome profile dedicated to private receives. Generate a new seed entirely inside the second app. The two wallets share no derivation history; the only artefact tying them together is that they exist on the same machine. Meaningfully stronger, roughly five minutes of setup.
Path three: full keypair generation on a separate device or via the CLI. Run solana-keygen new --outfile ~/receive-only.json on a machine you do not use for other crypto activity, or better, on a fresh user account on a clean OS. Import the keypair into Phantom or Solflare only when you need to spend, or never import it and sign onward transactions via the CLI. Maximum separation, appropriate when the threat model includes subpoena power or device-level access. Overkill for a routine private payment.
A note on hardware wallets. Phantom + Ledger and Solflare + Ledger both work normally as withdraw destinations. The Ledger derives a fresh Solana address on-device under whatever derivation path you select; that address has no on-chain history until you use it. The withdraw transaction does not require the Ledger to sign anything — the relayer signs and submits — so you can configure the receiving Ledger address, hand it to the sender, and not touch the device again until you want to spend. The privacy properties of the withdraw are identical whether the recipient is a hot wallet or a hardware wallet.
For walkthroughs of these flows with screenshots, see /learn/choosing-a-recipient-address.
How can a fresh wallet receive funds with zero SOL in it?
The single most useful operational property of SolMask withdraws is that they do not require the recipient address to hold any SOL whatsoever. A wallet that has never existed on-chain — zero balance, no account rent paid, no associated token accounts created — can receive 1,000 USDC privately, in one transaction, with no prior funding step. This is not magic; it is just careful instruction layout inside the withdraw, and it is worth understanding because the alternative — pre-funding the fresh wallet with a little SOL "for gas" — is the second most common way users deanonymise themselves.
Here is what happens inside a single SolMask withdraw transaction when the asset is USDC and the recipient address is brand new:
- The relayer signs the transaction. The relayer is the fee payer for the transaction's base Solana fee.
- The program verifies the Groth16 proof against the current Merkle root and the supplied nullifier. If the proof is valid and the nullifier is unspent, execution continues.
- The program checks the recipient's associated token account (ATA) for USDC. If it doesn't exist, the program creates it idempotently in the same transaction. The rent for the ATA — currently around 0.00203928 SOL — is paid by the relayer, not by the recipient.
- The relayer pays the 0.003 SOL on-chain withdraw fee (the current
withdraw_fee_lamports) from its own wallet and recoups it by keeping a small USDC tip slice out of the withdrawn amount. The recipient receives the USDC they were expecting, net of that small tip; no SOL ever has to touch the recipient's wallet. - The withdraw releases the USDC into the relayer-controlled account, and the recipient's ATA receives it net of the small tip slice the relayer keeps to cover the protocol fee.
- The program records the nullifier as spent, preventing double-spend of the same note.
- The
unlock_slotfield on the note — the timelock that prevents instant deposit-to-withdraw correlation — has already been checked against the current slot by step 2. If the unlock has not passed, the proof verification fails earlier.
The recipient holds USDC. The recipient's ATA exists. The recipient's SOL balance is still zero — they neither paid the ATA rent nor paid the 0.003 SOL fee out of their own SOL. The relayer absorbs the network gas and the rent; it is made whole only on the protocol fee, which it recoups from the USDC tip slice. Everyone is whole.
The deposit asset shape changes the details but not the principle. For SOL deposits, the relayer's tip slice is taken in SOL directly. For USDC and USDT, the tip is taken in that same asset. If the recipient wants a different asset than was deposited, a Jupiter swap inside the same withdraw converts the delivered amount — but the fee itself is always recouped from a small slice of the deposit asset. The recipient never needs to fund anything.
This is the property that makes private receives operationally clean. You can ask someone to pay you in USDC, give them a fresh Phantom subaccount address, and the moment the withdraw lands, you can use the funds. No pre-funding, no on-chain breadcrumb from your main wallet to the fresh one, no waiting for SOL to arrive from somewhere first.
Why is funding a fresh wallet for gas the worst thing you can do?
Imagine you've done the work. You generated a clean Phantom subaccount. You handed the address to a contractor over Signal. They deposit 1,000 USDC into the SolMask USDC pool (deposits are free), wait for the unlock slot, and request a withdraw to your fresh address — the 23 bps fee is taken on the way out, so you receive 1,000 USDC less the fee. The withdraw is queued.
Then you have a thought: "I should probably send 0.01 SOL from my main wallet to the fresh one, just so it has gas." You do this. Twelve seconds later, the SolMask withdraw lands. Now your fresh wallet has 1,000 USDC plus 0.01 SOL.
You have destroyed the privacy of the entire flow.
The on-chain footprint of the fresh address now reads, in order: (1) 0.01 SOL inbound from your-main-wallet.sol, (2) 1,000 USDC inbound from the SolMask withdraw vault. The graph edge between your main wallet and the fresh wallet is permanent and public. Any observer with prior context on your main wallet — and that is anyone who has ever seen you tweet your address, anyone you have ever paid from your main wallet, the recipient of any donation you've made, the chain-analysis dashboards that already cluster your main wallet under your real identity — can now trivially identify the fresh wallet as yours. The SolMask withdraw broke the link from the contractor's deposit to your fresh wallet; you re-established the link to yourself in a different direction by funding the gas.
The fix is the one we already covered: don't fund the fresh wallet at all. The withdraw creates the ATA, pays the rent, covers the on-chain fee, and delivers the asset, all without the recipient providing any SOL. For a pure receive flow — money in, sit on it — you are done. There is nothing to fund.
If you need ongoing SOL on the fresh wallet because you intend to swap the received USDC into something else, do not transfer SOL in from your main wallet. The cleaner options are:
- Do a second, smaller SolMask deposit + withdraw of SOL, into the same fresh wallet, from a depositor source that is not linked to you. (If you do it from your own funds via a different deposit, the depositor side is private from the public chain, but you should still pause on whether the operational separation gets you what you want.)
- Receive a small SOL top-up from the same sender via SolMask, in a separate deposit, using the same fresh address.
- Use a sibling fresh wallet for the swap, funded the same way.
The common pattern across these is: never let the on-chain graph see an edge between a wallet you control under your real identity and the fresh receiver. The whole point of the withdraw is to give you a wallet with exactly one inbound edge, originating from the protocol vault. Don't add a second edge.
For the full framing of which adversaries the protocol is designed to defeat and which it isn't, see /docs/threat-model.
A worked example: a $1,000 contractor payment
Concrete walkthrough. A contractor — call her Maya — is owed $1,000 for a piece of work. The payer is a small company that would prefer Maya's wallet not be visible on their treasury's public on-chain trail; Maya, separately, would prefer her wallet not be linked to the payer either. SolMask is the right tool for the job. Here is the full flow.
Maya opens Phantom. "Manage Accounts" → "Add / Connect Wallet" → "Create new wallet." Phantom derives a fresh keypair and shows her a new public address. The address has never appeared on-chain. Balance is zero. No USDC ATA exists yet.
Maya sends the address to the payer over Signal. Not email, not Slack, not a Notion doc — the only artefacts linking the recipient address to her identity should be in places she can audit and delete. Signal isn't perfect; it's a smaller surface than the payer's corporate email retention policy.
The payer receives the address. From their treasury wallet — which holds USDC — they call SolMask's deposit instruction with 1,000 USDC. Deposits are free, so the protocol commits a shielded note worth the full 1,000 USDC to the Merkle tree (the 23 bps fee is charged later, at withdraw time). The depositor's wallet shows a 1,000 USDC outflow to the SolMask pool; the chain knows the pool received 1,000 USDC; the chain does not know what the note's value is, who can spend it, or where it's going.
The note's unlock_slot is set to the current slot plus the configured delay — typically a few minutes in slots, long enough to make timing-based correlation against the deposit transaction noisy. The payer's part is now done.
After the unlock slot has passed, the withdraw is constructed. The depositor has shared the nullifier_secret and the recipient address with the relayer via the standard withdraw request (the relayer cannot derive identity from these; it only sees what it needs to build the proof and submit the transaction). The relayer builds the Groth16 proof against the current Merkle root, signs the transaction, and submits.
The withdraw executes. The program verifies the proof, computes the nullifier from nullifier_secret, checks it's unspent, marks it spent. It creates Maya's USDC ATA. The relayer pays the 0.003 SOL withdraw fee from its own wallet and keeps a small USDC tip slice to recoup it. The vault releases 1,000 USDC; Maya's ATA receives that amount net of the tiny tip. Her wallet shows ~1,000 USDC. Her SOL balance is still zero.
The chain now shows: (1) the payer's treasury sent 1,002.30 USDC to the SolMask pool, (2) the SolMask withdraw vault sent 1,000 USDC to Maya's fresh address. No on-chain link between (1) and (2). The Merkle root has been updated; the nullifier set grew by one. A chain analyst drawing an edge between the payer and Maya finds the pool in the way and the cryptography unbroken.
Maya now treats her fresh wallet like a one-time envelope. If she wants to swap the USDC, she can do so directly on the fresh wallet — but the moment she does, the wallet's purpose becomes more visible on-chain. If she sends the USDC onward to a centralised exchange under her name to off-ramp to fiat, the privacy ends there (which is fine; that's the standard endpoint). If she sends it onward to her main wallet, she has just linked her main wallet to a SolMask withdraw, which is recoverable in the chain analyst's graph and at minimum tells the world "Maya received funds privately from someone." Whether that's a problem depends on her threat model.
For a sanity-checking flow that confirms the withdraw landed in the correct address and the note was spent exactly once, see /learn/verifying-your-deposit.
What does this not solve?
Network-layer linkage. SolMask breaks the on-chain link between deposit and withdraw; it does not break the network-level link between you and whatever RPC endpoint you use to query Solana. If the relayer submits the withdraw via a major public RPC, and five seconds later you open your fresh wallet and it pings the same RPC from your home IP to fetch the new balance, an adversary with access to RPC logs sees both endpoints and can correlate. Not unique to SolMask — it's a property of any system using shared network infrastructure for the private operation and what follows.
The mitigation, in increasing order of effort: use a different wallet app on a different device to check the balance and to make the first onward transaction; use a different RPC endpoint than the relayer's (Phantom and Solflare both let you configure custom RPCs); use a different network entirely (a different ISP, a VPN with a clean exit, Tor for the wallet's RPC traffic). For most users this is overkill. For a sufficiently determined adversary it is necessary.
The other thing this does not solve is the out-of-band linkage. The payer knows the recipient address because you told them. If the payer's records are subpoenaed, or their email is breached, or they tweet about it, the link from the recipient address to your identity exists in their world. The protocol's guarantee is that the on-chain link is broken; the off-chain link is your problem to manage. Channel selection matters: Signal beats email; in-person beats Signal; never writing the address down anywhere except where you have to is best.
For the SOL-specific flow rather than USDC, see /private/sol. For the USDC pool details, see /private/usdc.
FAQ
Does the recipient need any SOL in their wallet to receive a SolMask withdraw? No. The withdraw transaction creates the recipient's ATA if missing and pays the rent from the relayer's account. The relayer also fronts the 0.003 SOL protocol fee and recoups it from a small tip slice in the withdrawn asset, while absorbing the network gas itself. A wallet with literally zero balance can receive USDC, USDT, or SOL.
Should I fund the fresh wallet with a little SOL from my main wallet, just to be safe? No, and this is the single most common deanonymisation mistake. Funding the fresh wallet from your main wallet creates a permanent, public on-chain edge from a wallet linked to your identity to the wallet you just took pains to keep clean. The withdraw transaction handles all gas requirements internally; you do not need to pre-fund anything.
Can I receive to a Ledger-derived address? Yes. The withdraw does not care whether the recipient is a hot wallet, a Phantom subaccount, a Solflare subaccount, or a Ledger-derived address. The privacy properties are identical. The Ledger just adds operational security to whatever you do with the funds after they arrive.
What if the sender already knows my main wallet — does that ruin the privacy? It depends on whether they make the link explicit. The on-chain withdraw goes to your fresh address, not to your main wallet, so the chain itself does not show "sender paid main wallet." If the sender keeps the fresh address private and doesn't, for example, send a second payment from the same source to your main wallet later, the off-chain link stays with them and the on-chain trail stays clean. The protocol cannot stop someone you've told from talking; it can stop the chain from showing.
Once I receive the funds, can I keep using the fresh wallet? You can, but every subsequent transaction it makes is publicly linked to the SolMask withdraw that funded it. Treat it as a one-purpose envelope. For ongoing private operations, do another deposit + withdraw to a different fresh address. Reusing the receiving wallet for unrelated activity gradually builds a profile that may not be what you want.
What about the unlock_slot delay — does that affect receiving? The unlock slot is checked at withdraw time, not receive time. If the deposit's unlock slot has not yet passed when the withdraw is attempted, the proof verification fails. From the recipient's perspective this just means there's a short delay between the sender depositing and the funds arriving at the fresh address. The delay is what prevents instant timing-based correlation between the deposit and the withdraw.
Can I give the same fresh address to multiple senders? Technically yes, but you should not. Multiple senders depositing to fund withdraws to the same recipient address creates a pattern — same destination, different sources, similar amounts — that is observable on the chain even though each individual link is broken. If you are receiving from multiple parties, use a fresh recipient address for each, and treat each receive as an independent flow.