Skip to main content
b402 uses an on-chain privacy pool to provide payment privacy. When using incognito mode, tokens are moved into a private pool where transfers happen without revealing sender, recipient, or amount on-chain. This guide covers the cryptographic foundations and API surface.

Privacy Pool Overview

The b402 privacy pool is an on-chain privacy system that uses zero-knowledge proofs to shield token balances. Once tokens are inside the privacy pool contract, they can be transferred privately and withdrawn to any address, breaking the on-chain link between sender and recipient. The system has three operations:
OperationDirectionWhat Happens
ShieldPublic to privateDeposit tokens into the privacy pool
TransactPrivate to privateTransfer tokens within the pool (ZK proof)
UnshieldPrivate to publicWithdraw tokens from the pool to any address
All three operations interact with the b402 Relayer contract on supported chains, which forwards calls to the privacy pool:
ContractAddress
B402 RelayerV30xE91b564EB8DFF305Ff8efA332f84c487b9da5171

Commitment Structure

Every shielded balance is represented as a commitment stored in the privacy pool contract. A commitment is a Poseidon hash of:
commitment = Poseidon(npk, token, value, random)
FieldDescription
npkNullifier public key (derived from the recipient’s viewing key)
tokenToken address, type, and sub-ID packed together
valueAmount of tokens being shielded
randomRandom blinding factor for privacy
The Poseidon hash function is used instead of Keccak256 because it is efficient inside ZK circuits, making proof generation practical.

Commitment Fields in the Database

When a shield event is indexed, the following fields are stored:
{
  transactionHash: string   // On-chain tx that created the commitment
  commitmentHash: string    // The Poseidon hash (leaf of the Merkle tree)
  treeNumber: string        // Which Merkle tree this commitment lives in
  position: string          // Leaf position within the tree
  tokenAddress: string      // ERC-20 token address
  tokenType: string         // Token standard (ERC-20, ERC-721, etc.)
  tokenSubID: string        // Sub-ID for ERC-1155 tokens (0 for ERC-20)
  amount: string            // Raw token amount (wei)
  fee: string               // Shield fee deducted by the privacy pool
  npk: string               // Nullifier public key
  encryptedBundle0: string  // Encrypted note data (for recipient's wallet)
  encryptedBundle1: string  // Encrypted note data
  encryptedBundle2: string  // Encrypted note data
  shieldKey: string         // Encryption key for the bundle
}

Nullifier Generation

Nullifiers prevent double-spending. When a commitment is consumed (via transact or unshield), a nullifier is published on-chain. The nullifier is derived deterministically from the commitment but cannot be linked back to it without the private key.
nullifier = Poseidon(commitment, pathIndices, spendingKey)
The on-chain contract maintains a set of all spent nullifiers. Before accepting a new transaction, the contract checks that the nullifier has not been seen before.

Nullifier States

StateMeaning
UnusedCommitment has not been spent, balance is available
Used (TRANSACT)Commitment was consumed in a private transfer, producing new output commitments
Used (UNSHIELD)Commitment was consumed in a withdrawal to a public address

Merkle Tree Structure

The privacy pool organizes commitments in a binary Merkle tree using Poseidon hashing. The tree has:
  • Depth: 16 levels
  • Capacity: 2^16 = 65,536 leaves per tree
  • Hash function: Poseidon (ZK-friendly)
  • Zero value: keccak256("Railgun") % SNARK_SCALAR_FIELD
When a tree fills up, a new tree is created (incrementing treeNumber).

Tree Construction

Privacy pool Merkle tree: 16 levels deep, 65,536 leaves, Poseidon hashing
Each non-leaf node is the Poseidon hash of its two children:
node = Poseidon(leftChild, rightChild)
Empty leaves use the zero value, and empty subtrees use precomputed zero hashes:
ZERO_HASHES[0] = ZERO_VALUE  // keccak256("Railgun") % SNARK_SCALAR_FIELD
ZERO_HASHES[1] = Poseidon(ZERO_HASHES[0], ZERO_HASHES[0])
ZERO_HASHES[2] = Poseidon(ZERO_HASHES[1], ZERO_HASHES[1])
// ... up to level 16

Merkle Proof

To spend a commitment, the user must prove it exists in the tree by providing a Merkle proof: the sibling hashes along the path from the leaf to the root.
interface MerkleProof {
  proof: string[]      // 16 sibling hashes, one per level
  root: string         // Current tree root
  leafIndex: string    // Position of the commitment in the tree
  pathIndices: number[] // 0 = left child, 1 = right child at each level
  treeDepth: 16
  leaf: string         // The commitment hash being proven
}
Verification walks from the leaf to the root:
function verifyMerkleProof(
  leaf: string,
  proof: string[],
  pathIndices: number[],
  root: string
): boolean {
  let currentHash = leaf

  for (let i = 0; i < proof.length; i++) {
    const sibling = proof[i]
    const isRightNode = pathIndices[i] === 1
    currentHash = isRightNode
      ? poseidon([sibling, currentHash])
      : poseidon([currentHash, sibling])
  }

  return currentHash === root
}

Shield Flow in b402

Shielding is the process of depositing tokens from a public address into the b402 privacy pool.
Shield flow: User approves, shields tokens, privacy pool creates commitment, backend indexes
After shielding, the commitment is stored in the database and the Merkle tree is updated. The user’s wallet SDK can then scan for their commitments using the encrypted bundle data.

Unshield Flow

Unshielding withdraws tokens from the privacy pool to any public address. This is the most complex operation because it requires generating a zero-knowledge proof.

Step 1: Generate the ZK Proof

The user’s wallet generates a proof that:
  • They own a valid commitment in the Merkle tree
  • The nullifier has not been spent
  • The output amounts are correct (recipient amount + fee)
The proof is encoded as transaction data targeting the privacy pool relay contract.

Step 2: Submit and Broadcast

The facilitator handles unshield submission internally. After the ZK proof is generated, it is submitted to the privacy pool relay contract.

Security Considerations

Proof Validation

  • Transaction data size is validated (must be under 100KB) to reject junk submissions
  • The transactionTo field must match the configured privacy pool relay contract address
  • Nullifiers are checked against the on-chain database to prevent double-spend attempts
  • Merkle proofs are verified server-side before being returned to clients

Data Integrity

  • Merkle proofs are verified before being returned (the backend recomputes the root from the proof path)
  • If a proof fails verification, an internal server error is returned indicating database inconsistency
  • Nullifier records must always have an associated transact or unshield record; orphaned nullifiers trigger an error

Further Reading