Smart Contracts on Stellar

Prototyping Privacy Pools on Stellar

Author

Yan Michalevsky

Publishing date

Introduction

The following describes the prototyping of Privacy Pools on the Stellar smart contracts platform and the Stellar network. Privacy Pools [1] is a way to conduct privacy preserving transfers by managing a pool of funds from which depositors can withdraw, while obscuring the link between withdrawals and deposits. An innovative aspect of Privacy Pools is the incorporation of Association Set Providers (ASPs) that define inclusion criteria intended to help mitigate illicit use. ASPs enable participants to selectively associate with sets of other participants who meet their chosen compliance standards. Privacy guarantees are achieved by using cryptographic zero-knowledge techniques that enable proving useful properties of transactions without revealing their full details.

Privacy Pools Design

Rather than coming up with a novel scheme, we seek to achieve parity on Stellar with capabilities that were already implemented for Ethereum by frameworks such as 0xbow [5]. Our scheme closely follows Privacy Pools by Buterin et al. [1]. Privacy Pools are essentially a privacy-preserving protocol that incorporate ASPs that specify compliance standards intended to help mitigate illicit use and enable participants to choose which ASP, and which compliance standards, to associate with [1].

A mixer (Fig. 1) obscures the link between withdrawers and depositors by enabling the withdrawers to prove they previously deposited a sufficient amount of funds into the mixer contract, without revealing the exact deposit. Note that the extent of privacy a participant enjoys corresponds to the anonymity set which, in our case, is the number of deposits preceding a withdrawal.

Figure 1. A “mixer” obscures the link between deposits and withdrawals.

Mixer Scheme

Let us elaborate on the cryptographic scheme that enables mixing. The depositor generates two random numbers, a secret s, and a nullifier n. It computes a hash c=H(s, n) which is called a commitment. The commitment c is provided to the contract along with a deposit. The contract stores the commitment as a new leaf in a Merkle tree. In order to withdraw the funds, the withdrawer must prove that they know (s, n) such that H(s, n) is one of the commitments previously recorded by the mixer. The withdrawer does not reveal s or n. They just prove using a SNARK that H(s, n) is included in the Merkle-tree with a root value equal to the one computed independently by the mixer contract.

In order to prevent double spending the withdrawer is also required to provide a nullifier-hash H(n) as part of the SNARK outputs. The mixer contract stores the nullifier hashes, and for each withdrawal attempt checks that the provided nullifier-hash is not among the previously recorded nullifier-hashes, i.e. the coin has not been withdrawn yet. Note that the SNARK guarantees that it is the same n in both H(n) and H(s, n) provided to the mixer, without revealing the nullifier n itself.

Note: Domain separation between pools
In practice, in order to guarantee domain separation between different pools we use a label l which is a concatenation of the pool name and a 32-byte nonce. Instead of c=H(s,n) we compute c=H(H(s,n), l) as the coin commitment.

Privacy Pools

Privacy Pools extend the basic mixer design by incorporating ASPs that specify compliance standards intended to help mitigate illicit use. To withdraw a coin, the recipient must prove they qualified for an ASP and that, therefore, they satisfied the ASP's compliance standards. This is done by providing a zero-knowledge inclusion proof. In fact, the leaves in such an association set could be the label-s used by participants for generating coin commitments and mentioned above in the context of domain separation.

Implementation

The Stellar Privacy Pools implementation is inspired by 0xbow [6]. Its Github repository contains the relevant circuits for the ZK-proofs of coin ownership and Merkle-tree inclusion.

The ZK scheme is Groth16 using a BLS12-381 curve, supported by Soroban.

Toolchain

Figure 3. Toolchain for contract deployment.

For proving statements about transactions, we use the Circom compiler. It enables us to describe computations with the convenience of the Circom language and build circuits for which we can generate zero-knowledge proofs. For maximum efficiency of proof generation and verification, we use the Groth’16 SNARK scheme [7]. Verifying a SNARK (as complex as the statement may be) requires only a single bilinear pairing computation. This takes approximately 40 million instructions in a Soroban contract, which is %40 of the maximum instruction budget on testnet.

In our codebase the main.circom circuit (Fig.2) is used to generate the SNARK for withdrawing a coin. The circuit takes as inputs the coin generation parameters (s, n). The Merkle-tree root, and the siblings along the commitment leaf’s path. It proves that the Merkle-tree root computed independently by the circuit and the input root are equal, and outputs the nullifier hash.

Figure 2. main.circom: A Circom circuit proving a coin was previously deposited into the mixer.

Preventing frontrunning by a relaying party

If we naively check a Zero-Knowledge proof of depositing a coin without checking who is withdrawing we enable a relaying party to withdraw to any desired address by editing the transaction. The Zero-Knowledge proof must include the recipient address. This is not yet implemented in main.circom.

To run a ZK verifier in Soroban we use a no_std compatible implementation of a Groth16 verifier, provided in https://github.com/stellar/soroban-examples/tree/main/groth16_verifier.

We adapted the circuits from the 0xbow repository and built them using Circom.

We implement conversion functions to use Circom-generated outputs with the Groth’16 verifier (it is important to ensure that we use the same curve (BLS12-381).

circom2soroban

Since Soroban supports only a limited subset of Rust and Ark and precludes from using any std features, there are some challenges around conversion of Circom outputs to inputs compatible with the Groth’16 Verifier module. We therefore create an off-chain tool that would process the Circom outputs and serialize them, such that they can be easily deserialized by our Soroban contract.

circom2soroban is a command-line tool written in Rust that converts Circom JSON outputs into Rust code that can be embedded into a Soroban smart contract. circom2soroban accepts two arguments. The first indicates the type of input, whether it is a verification key or a proof. It can be one of three values, vk (for verification key), proof (the SNARK) or public (for public inputs and outputs). The second argument is the path to the JSON file containing the key or proof output by Circom. In addition to Rust variables that can be pasted into a smart contract as hardcoded parameters, circom2soroban can output a conversion to bytes that can be used when constructing a contract or invoking one of its methods.

Fig. 3 illustrates the toolchain used to deploy a mixer contract, where constants generated by SnarkJS from Circom compiler’s output are converted using circom2soroban and provided to the contract constructor.

coinutils

coinutils is a CLI tool that handles the practical aspects of privacy pool participation. It manages the complete lifecycle of privacy pool coins, from generation to withdrawal preparation.

The generate command creates new coins with cryptographically secure parameters. It generates a secret s, nullifier n, and computes the commitment using Poseidon hashing over BLS12-381 field elements. The tool outputs both decimal and hex formats, with the hex format ready for direct contract integration.

For withdrawal, the withdraw command takes a coin file and a state file containing the Merkle tree of commitments. It reconstructs the Merkle tree, generates the necessary Merkle inclusion proofs, and produces SNARK-compatible inputs for the main.circom circuit. If association sets are used to verify participation eligibility, it also generates a proof of legitimate participation.

The updateAssociation command manages lists of participants who have been approved by ASPs according to their respective compliance standards and builds Merkle trees to enable verification of participant approval status. It maintains an association set Merkle-tree with a certain depth, supporting up to 2depth labels per set.

All operations work directly with BLS12-381 field elements, ensuring compatibility with the Soroban environment and Groth16 proofs. The tool uses decimal string representation for human readability while providing hex outputs for contract integration. It seamlessly integrates with the broader toolchain—generated coins can be deposited directly into contracts, and withdrawal inputs are ready for SnarkJS proof generation.

Future suggestions

  • SnarkJS is one of the standard tools for ZK-related stuff (proof generation, verification key generation, Powers-of-Tau, etc.). It is capable of outputting verification code in Solidity that can be directly used in Ethereum (and more generally Solidity-compatible smart contracts). We should consider extending SnarkJS to similarly generate Soroban-compatible verification code.

Future Work

  • Frontrunning protection – include recipient address in ZK proof.
  • Store last N roots in contract to allow the withdrawer to withdraw while new deposits are made.
  • “Rage-quit” functionality - allow a user to withdraw their deposited funds publicly if they were not approved via an association set.