Contracts

Privé runs on Robinhood Chain Testnet, chain id 46630. Three contracts are deployed: the PrivacyPool that holds the shielded balance and the commitment tree, the Groth16 WithdrawVerifier that checks withdrawal proofs on chain, and MockUSDG, a test token with an open mint that backs the faucet. The contracts are not audited and hold testnet value only.

Deployed addresses

These are the live addresses from the deployment record for chain 46630. Everything below is verifiable on the block explorer.

ContractAddressRole
PrivacyPool0xd62C837a70b9552637C8ee1681fb09b459648181Deposits, withdrawals, commitment tree, nullifier set. Holds all pooled USDG.
WithdrawVerifier0xBAFE6434C40e5582b11bA90cFd6cA1f5ebd904BFsnarkjs-generated Groth16 verifier for the withdraw circuit. Stateless and view-only.
MockUSDG0xF47593cac046C3a4C15B495eDAd59DE5868B6BbBTest-only ERC-20, 6 decimals, symbol USDG. Open mint. Testnet only.
ParameterValue
Chain id46630 (Robinhood Chain Testnet)
Deploy block89352628
Pool SCOPE0x160e01af87652d4bbe04f0c762e1aa4416be83fc6ab15d0c21457c703b6b3474

SCOPE is the immutable domain separator for this pool version. It is derived deterministically at deploy time as uint256(keccak256(“Prive.PrivacyPool.v0”)) % SNARK_SCALAR_FIELD, and it is mixed into both the deposit label and the withdrawal proof context. Clients should read it from the deployment file rather than hard-coding it.

PrivacyPool

PrivacyPool extends CommitmentTree, which holds a LeanIMT of note commitments, a circular history of the last ROOT_HISTORY_SIZE = 64 roots, and the spent-nullifier set. Three immutables are set in the constructor: VERIFIER, TOKEN (the pooled ERC-20), and SCOPE.

deposit

deposit(uint256 value, uint256 precommitment) pulls value tokens with transferFrom (approve the pool first) and inserts a commitment leaf. The precommitment is Poseidon(nullifier, secret), computed in your browser, so the pool never sees the note secrets. The contract assigns a fresh label from SCOPE and an incrementing nonce, records depositors[label] = msg.sender, and stores the leaf Poseidon(value, label, precommitment). Deposits must satisfy 0 < value < 2**128, otherwise InvalidDepositValue reverts.

withdraw

Withdrawal takes the Withdrawal target struct plus a Groth16 proof and its six public signals.

struct Withdrawal {
    address recipient;      // receives withdrawnValue - feeAmount
    address feeRecipient;   // relayer, or address(0) when self-submitting
    uint256 feeAmount;      // paid out of the same withdrawn USDG
}

function withdraw(
    Withdrawal calldata w,
    uint256[2] calldata pA,
    uint256[2][2] calldata pB,
    uint256[2] calldata pC,
    uint256[6] calldata pub
) external;

// pub = [
//   0: newCommitmentHash      change note re-inserted into the tree
//   1: existingNullifierHash  spent, prevents double spend
//   2: withdrawnValue
//   3: stateRoot
//   4: stateTreeDepth
//   5: context
// ]

The whole struct is bound into the proof: the pool recomputes context = uint256(keccak256(abi.encode(w, SCOPE))) % SNARK_SCALAR_FIELD and requires it to equal pub[5]. A relayer therefore cannot redirect the recipient or inflate the fee after a proof is generated. Any tamper reverts with ContextMismatch. Self-submission uses feeRecipient = address(0) and feeAmount = 0.

On success the pool spends pub[1], inserts pub[0] as the change note, transfers pub[2] - w.feeAmount to the recipient, and transfers w.feeAmount to the fee recipient when it is non-zero. Because withdrawals are partial, the remainder stays shielded as a new note.

Roots and nullifiers

  • latestRoot() returns (uint256): the current LeanIMT root, or 0 before the first insert.
  • isKnownRoot(uint256 root) returns (bool): true if the root is in the last 64 roots. This lets a proof built against a slightly stale root still land.
  • isSpent(uint256 nullifierHash) returns (bool): the public spent-nullifier mapping. A second spend reverts with NullifierAlreadySpent.
  • depositors(uint256 label) returns (address): the depositor recorded for a label, and nonce, the deposit counter.

Events

  • Deposited(address indexed depositor, uint256 commitment, uint256 label, uint256 value, uint256 precommitment)
  • Withdrawn(address indexed recipient, uint256 withdrawnValue, uint256 nullifierHash, uint256 newCommitment)
  • LeafInserted(uint256 indexed leaf, uint256 leafIndex, uint256 root): emitted on every insert, deposits and change notes alike. Clients rebuild the local Merkle tree from this event, starting at the deploy block.
  • NullifierSpent(uint256 indexed nullifierHash)

Custom errors

InvalidProof, ContextMismatch, UnknownStateRoot, InvalidDepositValue, ZeroWithdrawal, FeeExceedsValue, ZeroRecipient, and NullifierAlreadySpent from the commitment tree. ZeroWithdrawal blocks burning a nullifier for no payout, and FeeExceedsValue requires feeAmount < withdrawnValue.

WithdrawVerifier

The verifier is generated by snarkjs from the compiled withdraw circuit and exposes a single view function:

function verifyProof(
    uint256[2] calldata pA,
    uint256[2][2] calldata pB,
    uint256[2] calldata pC,
    uint256[6] calldata pubSignals
) external view returns (bool);

The circuit proves membership of an existing note in the state tree, correct nullifier derivation, a 128-bit range check on the withdrawn and remaining values, and a fresh nullifier for the change note. It does not constrain an association-set root today. The compliance layer, the Association Set Provider and proof-of-innocence membership, is Planned and lands with the mainnet audit. See the compliance model for the design.

MockUSDG

MockUSDG is an ERC-20 named Global Dollar with symbol USDG and 6 decimals. It exposes mint(address to, uint256 amount) with no access control: anyone can mint any amount. That is deliberate, it powers the testnet faucet, and it means testnet USDG is worthless. The pool itself is token-agnostic and takes any IERC20 at construction, so a mainnet deployment would point at the real Paxos-issued USDG. MockUSDG must never be deployed to mainnet.

Connecting to chain 46630

The official RPC hostname for the testnet is https://rpc.testnet.chain.robinhood.com/rpc. Some ISPs and DNS resolvers block or fail to resolve that hostname. Privé does not depend on it: any standard EVM JSON-RPC endpoint for chain id 46630 works, for example a public provider endpoint or your own node. The web app reads NEXT_PUBLIC_RPC_URL as an override, so you can point it at a reachable endpoint without changing anything else. All reads in this page, deposits, event scans, and proof submission, go through that single RPC.

Status: Live on testnet, unaudited, no mainnet deployment. Do not send real value. See security and risk before you use the pool, and the testnet guide to get funded and make a first deposit.