Skip to main content
Arcane’s Stellar privacy pool uses a combination of Poseidon hashing, BabyJubJub elliptic curve operations, ECDH key agreement, and Groth16 zero-knowledge proofs to let users transact privately on Soroban. Private state is kept entirely off-chain inside user-held coins; only compact commitments and nullifier hashes are stored on the contract. This page explains each primitive and how they fit together.
The Poseidon hash implementation used by the circuits, contract helpers, and SDK WASM code is a prototype for research and educational purposes. It is not audited and exists to keep the Circom and Rust/WASM code consistent. Do not use it in production systems without an independent security review.

Coins and commitments

A private coin is a secret record that represents an amount of a specific asset inside the pool. Only the coin owner holds the full coin data — the contract stores only a compact cryptographic commitment derived from it.
FieldRole
valueThe amount, in stroops
asset[0], asset[1]The Stellar asset contract ID split into two field-element limbs
nullifierA secret random value used to prevent double-spending
secretDerived from the deposit ephemeral scalar; binds the coin to the deposit flow
sharedSecret[0], sharedSecret[1]ECDH shared-secret coordinates binding the coin to a specific recipient
commitmentThe public Merkle leaf hash stored on-chain

Commitment formula

The commitment for a coin is computed in three steps using Poseidon hashing:
precommitment     = Poseidon(secret, sharedSecret[0], sharedSecret[1])
balanceCommitment = Poseidon(value, asset[0], asset[1])
commitment        = Poseidon(nullifier, balanceCommitment, precommitment)
The commitment value is what gets inserted into the on-chain Merkle tree as a leaf. The underlying nullifier, secret, and sharedSecret values remain entirely private — they never appear on-chain.

Nullifiers

A nullifier prevents a coin from being spent more than once. When you spend a coin, the circuit computes nullifierHash = Poseidon(nullifier) and includes it in the public signals. The contract stores this hash and rejects any future transaction that tries to use the same hash. Because Poseidon is a one-way function, knowing the nullifierHash on-chain tells an observer nothing about the underlying nullifier or the coin it belongs to. You can compute a coin’s nullifier hash off-chain using the SDK:
const nullifierHash = await sdk.calculateNullifierHash(coin.nullifier);

// Check whether a coin has already been spent
const isSpent = await contract.is_nullifier_hash_consumed(nullifierHash);
Check is_nullifier_hash_consumed before building a proof. Proof generation is computationally expensive — confirming the coin is unspent first saves time if the nullifier has already been consumed.

Stealth addresses

A stealth address lets you receive private funds without linking transactions to a fixed on-chain identity. Arcane stealth addresses use the stpl1 Bech32 address format, derived from the BabyJubJub elliptic curve.

Derivation flow

  1. Sign a message — Your Stellar wallet signs a deterministic message built by the SDK.
  2. Scalar derivation — The SDK hashes the signature with SHA-256 and interprets the result as a BabyJubJub scalar.
  3. Encode as stpl1 — The scalar maps to a BabyJubJub public point, which is Bech32-encoded with the stpl1 human-readable prefix.
// Step 1: Build the sign message
const message = PrivacyPoolSDK.buildStealthAddressSignMessage(stellarAddress);

// Step 2: Sign with the Stellar wallet
const signature = await wallet.signMessage(message);

// Step 3: Derive the stpl1 stealth address
const sdk = await PrivacyPoolSDK.init({ wasmBinary, circuitWasm, zkey });
const stealthAddress = await sdk.generateStealthAddressFromStellarSignature(signature);
// "stpl1…"
The same Stellar wallet always produces the same stealth address, so you only need to perform this derivation once per wallet. Share the resulting stpl1 address with depositors so they can direct funds to you privately.

Deposit and withdrawal flow

Private deposits and withdrawals use ECDH key agreement on the BabyJubJub curve so that only the intended recipient can recover a coin. Deposit (sender side):
  1. The depositor samples a random ephemeral scalar.
  2. The ephemeral scalar maps to an ephemeral BabyJubJub public key.
  3. ECDH between the ephemeral scalar and the recipient’s stpl1 public key produces a shared secret.
  4. The circuit sets secret = Poseidon(ephemeralScalar) and incorporates the shared secret coordinates into the commitment.
  5. The ephemeral public key is stored on-chain alongside the commitment leaf so the recipient can find it.
Withdrawal (recipient side):
  1. The recipient holds the private BabyJubJub scalar that underlies their stpl1 address.
  2. The recipient reads the depositor’s ephemeral public key from the on-chain leaf (via get_leaf_ephemeral()).
  3. ECDH between the recipient’s private scalar and the ephemeral public key reproduces the same shared secret.
  4. With the shared secret recovered, the recipient reconstructs the full coin, builds a Merkle witness, and generates the withdrawal proof.
The private scalar never leaves the client. Only the public stpl1 address and the on-chain ephemeral public key are ever visible outside your application.

Zero-knowledge proofs

Arcane uses Groth16 proofs to validate transactions on the Soroban contract without revealing any private inputs. Groth16 is a succinct, non-interactive zero-knowledge proof system: the proof is small, verification is fast, and the verifier learns nothing beyond what the public signals explicitly encode.

What the circuit enforces

The circuit checks all of the following without revealing the underlying values:
  • The input coin’s commitment exists as a leaf in the Merkle tree (Merkle inclusion).
  • The input coin’s commitment was constructed correctly from its private fields (commitment integrity).
  • The nullifier hash matches Poseidon(nullifier) for the spent input coin (nullifier binding).
  • Output commitments are correctly formed from the stated output coin fields.
  • The value balance is conserved across inputs, outputs, public deposits, and public withdrawals.

Proof generation

Use sdk.proveWithdrawal() or sdk.proveTransaction() to generate a proof, then serialize it with sdk.proofToHex() and sdk.publicToHex() before passing the bytes to transact:
const proofResult = await sdk.proveWithdrawal({
  coin: coinToSpend,
  witness,
  publicWithdrawAddress: destinationStellarAddress,
  outputCoins: [],
});

const proofBytes = sdk.proofToHex(proofResult.proof);
const pubSignalBytes = sdk.publicToHex(proofResult.publicSignals);
The contract calls its stored Groth16 verifier with these bytes. If verification passes, it updates on-chain state and emits the AuditEncodedDigest event. If verification fails, the transaction is rejected and no state changes occur. See Client SDK for the full SDK API reference and installation instructions.