Before You Ship
The SDK handles all ZK cryptography — proof generation, Merkle tree management, nullifier derivation, and note encryption. Everything else is your responsibility. This page is a checklist of every decision you need to make before putting StealthPay in front of real users.
1 · Spending key custody
The spending private key is the master secret of every user's private balance. It is not their wallet key. It is a separate bigint that the SDK uses to generate ZK proofs and derive nullifiers. Whoever holds this key controls the funds.
What you must decide
☐ Who generates the key?
- →User generates it on their device (self-custody)
- →Your backend generates it and gives it to the user
- →Derived deterministically from something the user already has (e.g. MetaMask signature)
☐ Where is it stored?
- →Browser localStorage / IndexedDB — encrypted with user's password or MetaMask sig
- →Your server — encrypted at rest, access gated by auth
- →User's own storage — exported like a seed phrase, never on your server
☐ How is access gated?
- →Password + bcrypt (minimum)
- →Password + TOTP 2FA
- →MetaMask signature challenge — user signs a message, your app uses the sig to decrypt
- →Hardware wallet derivation — spending key derived from a signed message on ledger/trezor
Recommended approach for browser apps
Ask the user to sign a deterministic message with MetaMask. Use that signature as the encryption key to encrypt the spending private key in localStorage. The spending key never leaves the browser, and it is tied to wallet ownership — no additional password required.
// Derive spending key from MetaMask signature
const sig = await signer.signMessage("StealthPay spending key v1");
const spendingPrivkey = BigInt(ethers.keccak256(sig)) % BN254_PRIME;
// Store encrypted in localStorage
const encrypted = encrypt(spendingPrivkey, sig); // your encryption layer
localStorage.setItem("stealthpay_key", encrypted);If the spending key is leaked
2 · Transaction signing strategy
The ZK proof proves note ownership. But a separate wallet still needs to submit the transaction and pay gas. These are two independent decisions. The contract does not care who submits — it only verifies the proof.
| Strategy | Who submits tx | Privacy | Complexity | Best for |
|---|---|---|---|---|
| User signs | User's wallet | Partial — wallet address visible on-chain | Low | P2P apps, self-custody tools |
| Backend signs | Your server wallet | Better — user wallet never touches pool | Medium | Payroll, enterprise, SaaS |
| Relayer network | Decentralised relayer | Full — no link to any user wallet | High | Consumer privacy apps, max privacy |
Decide who submits shield txs
Usually the user — they're depositing their own funds
Decide who submits privateSend txs
Can be user, backend, or relayer — no restriction
Decide who submits unshield txs
Relayer gives best privacy — user's address never linked to pool
Handle gas for non-user signers
Backend or relayer wallet needs native tokens (OG) for gas
If using a relayer — define your fee model
Relayer needs economic incentive; fee can be taken from unshield amount
3 · Note discovery
When someone receives a private transfer, their note (commitment, token, amount, salt) needs to reach them somehow so they can spend it later. The SDK supports 0G Storage hints for this — encrypted blobs posted on-chain that only the receiver can decrypt. But this requires 0G Storage to be available and the receiver to have their spending key ready to scan.
Enable 0G Storage hints (recommended)
Pass zeroGStorage config to SDK. Works automatically for privateSend.
Define a fallback if 0G Storage is unavailable
Store note payloads in your own database, encrypted with the receiver's spending pubkey
Handle note discovery for shield txs
Shield notes are only known to the shielder — make sure your app persists them across sessions
Decide how receivers find notes from direct deposits
If you shield on behalf of a user, you need to communicate their note payload to them securely
// Enable 0G Storage hints — SDK handles encryption + posting automatically
const sdk = new StealthPaySDK({
signer,
privacyPoolAddress: POOL_ADDRESS,
spendingPrivkey,
zeroGStorage: {
indexerRpc: "https://indexer-storage-testnet-standard.0g.ai",
rpc: "https://evmrpc-testnet.0g.ai",
},
});4 · UX decisions
Never show the spending key raw in the UI
Treat it like a seed phrase — export-only, behind a confirmation, never in plain text in a form
Show users their private balance, not their notes
Users think in balances, not UTXOs. Abstract note selection away.
Handle the proof generation wait time
ZK proofs take 30–60 s. Show a meaningful loading state — don't let users think the app froze.
Handle Merkle root staleness
If another tx lands between sync and spend, your Merkle root is stale and the proof will revert. Sync immediately before generating a spend proof.
Warn users before unshielding
Unshield makes the token publicly visible at the recipient address. Some users may not expect this.
Full checklist
Spending key
Transaction signing
Note discovery
UX