Builder Guide

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.

Skipping any item on this list is a product risk. Privacy protocols are only as strong as their weakest layer — and that layer is almost always key management or tx signing, not the ZK proofs.

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.

The SDK has zero opinion on where or how you store the spending key. It accepts a bigint and uses it. Storage, encryption, and access control are entirely your layer.

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.

typescript
// 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

There is no on-chain revocation. A leaked spending key means an attacker can drain all notes owned by that key. Speed is the only defence.
01Detect the leak immediately — monitor for unexpected unshield txs from your pool
02Unshield all notes to a fresh address as fast as possible
03Generate a new spending key and re-shield into fresh commitments
04Invalidate any server-side sessions that had access to the old key
05Inform the user — they need to know their privacy history may be exposed

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.

StrategyWho submits txPrivacyComplexityBest for
User signsUser's walletPartial — wallet address visible on-chainLowP2P apps, self-custody tools
Backend signsYour server walletBetter — user wallet never touches poolMediumPayroll, enterprise, SaaS
Relayer networkDecentralised relayerFull — no link to any user walletHighConsumer 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

typescript
// 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

Decided who generates the spending key
Chose a storage location (browser / server / user-held)
Implemented access gating (password, 2FA, or MetaMask sig)
Built a key export/backup flow for users
Defined an incident plan if a key is leaked

Transaction signing

Decided who signs shield txs
Decided who signs privateSend txs
Decided who signs unshield txs
Funded any backend/relayer wallet with native gas tokens
Defined relayer fee model if applicable

Note discovery

Enabled 0G Storage hints (zeroGStorage SDK config)
Built a fallback store for when 0G Storage is unavailable
Ensured shield notes persist across user sessions

UX

Spending key never shown raw in the UI
Balance shown instead of raw notes
Loading state shown during 30–60 s proof generation
Merkle sync happens immediately before each spend
Users warned before any unshield tx
If you have questions about any of these decisions, open an issue on GitHub or check the playground for working examples of each pattern.