Skip to main content
PrivacyPoolsContract stores all the state your application needs to construct valid witnesses, monitor pool activity, and verify that a coin has or has not been spent. Understanding this state model is essential when building deposit, withdrawal, or transfer flows — the getters described here are the primary read interface between your application and the pool.

State overview

StateGetterPurpose
Commitment leavesget_commitmentsThe public commitment hashes for every leaf inserted into the Merkle tree
Leaf countget_commitment_countThe total number of leaves currently stored in the tree
Leaf ephemeral keysget_leaf_ephemeral(index)The ephemeral BabyJubJub public key stored alongside each leaf, used for recipient-side shared-secret recovery
Merkle rootget_merkle_rootThe current root of the commitment tree
Merkle frontierget_pairwise_frontierThe pairwise LeanIMT insertion state used to compute the next root efficiently
Root historyis_known_root(root)Determines whether a given root is still within the accepted history window
Nullifier stateis_nulifier_hash_consumed(hash)Returns true if the nullifier hash has been permanently consumed by a prior transact call
Token balanceget_token_balanceThe public token balance currently held by the pool contract
Public slot configget_public_slot_configThe public input and output slot counts configured for the circuit

Reading pool state for witness construction

Before you can generate a proof for a withdrawal or transfer, your application needs a consistent snapshot of the current tree. You must read at least the Merkle root, the full list of commitments, and the leaf count from the contract before building the witness.
import { Contract, SorobanRpc, xdr } from "@stellar/stellar-sdk";

const server = new SorobanRpc.Server("https://soroban-testnet.stellar.org");
const contract = new Contract(PRIVACY_POOL_CONTRACT_ID);

// Read the current Merkle root — use this as stateRoot in your proof
const merkleRoot = await server.simulateTransaction(
  contract.call("get_merkle_root")
);

// Read all commitment leaves to reconstruct the tree locally
const commitments = await server.simulateTransaction(
  contract.call("get_commitments")
);

// Read the leaf count to know the tree size
const leafCount = await server.simulateTransaction(
  contract.call("get_commitment_count")
);

// Pass merkleRoot, commitments, and leafCount to your SDK witness builder
const witness = await sdk.buildWitness({ merkleRoot, commitments, leafCount, coin });
Read get_merkle_root and get_commitments as close together as possible. Another transaction landing between your two reads could change the root. If the root you used to build your proof has aged out of history by the time your transaction is submitted, the contract will reject it — rebuild your witness with a fresher root.

Root history

The contract does not require your proof to reference the most recent root. It accepts proofs built against any root that is still present in the root history ring buffer. This is important for applications where multiple users may be submitting transactions concurrently — a root that was current when you started building your proof may no longer be the latest root by the time your transaction lands, but it will still be accepted as long as it is within the history window.
ROOT_HISTORY_SIZE = 90
The is_known_root getter lets you check whether a given root is still in the history buffer before submitting. If is_known_root returns false, your proof will be rejected and you need to rebuild your witness from a more recent root.
In high-throughput scenarios, read the current root immediately before generating the proof — not before building the witness inputs — to give yourself the maximum possible window before the root ages out.

Commitment insertion

Every successful transact call stores either zero output commitments or exactly two output commitments. The contract never stores exactly one commitment — a transaction that provides exactly one non-zero output commitment is rejected. This aligns the on-chain state model with the circuit’s two-output design. Alongside each commitment leaf, the contract stores a leaf ephemeral key: a BabyJubJub public key that the recipient uses to recover the ECDH shared secret needed to decrypt the coin. You can fetch the ephemeral key for any leaf index using get_leaf_ephemeral(index).
If you are building a wallet that needs to scan for coins sent to a stealth address, iterate over all leaf indices using get_commitment_count and get_leaf_ephemeral(index) to recover the shared secret for each leaf and check whether it corresponds to a coin owned by your user.

Nullifier checks

When a coin is spent in a transact call, its nullifier hash is permanently recorded on-chain. The contract enforces the following process:
  • The circuit publishes nullifier hashes in the public signals.
  • Before verifying the proof, the contract checks every nullifier hash against stored nullifier state.
  • Any nullifier hash that is already marked consumed causes the entire transaction to revert.
  • After the proof verifies successfully, the contract marks all input nullifier hashes as consumed.
Only the nullifier hash is public. The raw nullifier value is never revealed on-chain — it remains private in the witness. You can query whether a specific nullifier hash has been consumed using is_nulifier_hash_consumed(hash).
Once a nullifier hash is consumed it cannot be unspent. Attempting to reuse a coin’s nullifier in a second transaction will always fail. Ensure your application tracks which coins have been spent locally to avoid constructing proofs that will be rejected.

Token legs

Public token movement happens inside transact as part of the same atomic transaction that verifies the proof and updates state. There are two possible directions:
Token legDirection
Public depositTokens move from the from address into the pool contract
Public withdrawalTokens move from the pool contract to the public Stellar account encoded in the public signals
Private transfers between pool commitments do not move tokens publicly — the only on-chain evidence of a private transfer is the new commitment leaves, the spent nullifier hashes, and the encrypted audit payload.