ZK Privacy Pools

Zero-knowledge privacy pools are the foundation of Obsqra.fi's privacy model. They enable users to deposit and withdraw funds without creating an on-chain link between the two transactions.

The Problem with Transparent DeFi

Every transaction on Ethereum is publicly visible. When you deposit into a yield protocol, anyone can see your address, your balance, and track your activity. This creates several problems:

Front-running

Front-running

MEV bots can see your pending transactions and front-run them, extracting value from your trades and deposits.

Copy Trading

Copy Trading

Competitors can monitor your treasury's positions and copy your strategy, reducing your alpha.

Targeted Attacks

Targeted Attacks

Large positions become targets for exploits, social engineering, or market manipulation.

Privacy Violations

Privacy Violations

Your financial activity is permanently recorded and can be analyzed by anyone, forever.

How Privacy Pools Solve This

Privacy pools break the link between deposits and withdrawals using cryptographic commitments and zero-knowledge proofs. Here's the key insight:

"Instead of storing who deposited what, we store cryptographic commitments that can only be redeemed by someone who knows the secret."

When you deposit, you don't reveal your identity. You submit a commitment - a hash of a secret that only you know. When you withdraw, you prove you know the secret without revealing it, using a zero-knowledge proof.

The Commitment System

What is a Commitment?

A commitment is a cryptographic hash that "commits" you to a secret value without revealing it. Think of it like a sealed envelope: you can prove the envelope contains something, but no one can see inside until you open it.

// Commitment generation
secret
= random_256_bits()
nullifier
= random_256_bits()
commitment
= Poseidon(secret, nullifier)
Component
Secret
Purpose
Proves ownership of deposit
Who Knows It
Only the depositor
Component
Nullifier
Purpose
Prevents double-spending
Who Knows It
Only the depositor (until withdrawal)
Component
Commitment
Purpose
Stored on-chain as deposit record
Who Knows It
Public (but reveals nothing)

Why Poseidon Hash?

We use the Poseidon hash function instead of keccak256 (Ethereum's native hash) because Poseidon is "ZK-friendly" - it requires far fewer constraints in a ZK circuit:

Hash Function
Poseidon
Constraints in Circuit
~240 constraints
Proof Time Impact
Fast (~5 seconds)
Hash Function
keccak256
Constraints in Circuit
~150,000 constraints
Proof Time Impact
Slow (~5 minutes)
Hash Function
SHA256
Constraints in Circuit
~25,000 constraints
Proof Time Impact
Medium (~1 minute)

The Nullifier System

The nullifier prevents double-spending. When you withdraw, you reveal the nullifier (but not the secret), and the contract marks it as "used." If you try to withdraw again with the same nullifier, the transaction reverts.

💰Deposit

Commitment stored

🔓First Withdraw

Nullifier revealed and marked

Second Attempt

Reverts: nullifier used

ℹ️
Why Not Just Use the Secret?
If we revealed the secret during withdrawal, an attacker could front-run your withdrawal transaction and steal your funds. The nullifier is derived from the secret but doesn't reveal it, so even if someone sees your nullifier, they can't withdraw your other deposits.

Zero-Knowledge Proof Verification

The magic happens in the ZK proof. During withdrawal, you generate a proof that says:

"I know a secret and nullifier such that:

  1. 1. Poseidon(secret, nullifier) equals one of the stored commitments
  2. 2. Poseidon(nullifier) equals the nullifier hash I'm revealing
  3. 3. I want to send the funds to recipient
  4. 4. The amount is amount

...without revealing the secret or which commitment is mine."

The Groth16 verifier contract checks this proof in about 200,000 gas. If the proof is valid, the contract knows you're entitled to withdraw, but doesn't know which deposit you're claiming.

What the Verifier Checks

Check
Commitment exists
What It Proves
Deposit was made
Public or Private
Public (commitment visible)
Check
Nullifier is fresh
What It Proves
Not double-spending
Public or Private
Public (nullifier revealed)
Check
Proof is valid
What It Proves
Prover knows the secret
Public or Private
Private (secret hidden)
Check
Amounts match
What It Proves
Correct withdrawal amount
Public or Private
Public (in transaction)

Privacy Guarantees

Let's be precise about what privacy you get and what you don't:

✓ What's Hidden

  • • Which deposit you're withdrawing
  • • Your depositor address
  • • Link between deposit and withdrawal
  • • Your total position in the pool

⚠ What's Visible

  • • That a deposit happened (amount visible)
  • • That a withdrawal happened (amount visible)
  • • Withdrawal recipient address
  • • Total pool TVL
⚠️
Anonymity Set Size Matters
Your privacy is only as good as the "anonymity set" - the number of deposits your withdrawal could be linked to. If there's only one deposit of exactly 1.234 ETH and you withdraw 1.234 ETH, the link is obvious. Use common deposit amounts and wait for more deposits before withdrawing.

Implementation Details

Deposit Function

PoolController.sol
function deposit(
    bytes32 commitment,
    uint256 amount
) external returns (uint256 index) {
    require(amount > 0, "InvalidAmount");
    require(!commitments[commitment], "CommitmentAlreadyUsed");
    
    // Store commitment
    commitments[commitment] = true;
    depositIndex++;
    
    // Transfer wETH from user
    IERC20(weth).transferFrom(msg.sender, address(this), amount);
    
    // Deploy to strategies
    _allocateToStrategies(amount);
    
    emit Deposit(commitment, amount, depositIndex);
    return depositIndex;
}

Withdraw Function

PoolController.sol
function withdraw(
    bytes32 nullifier,
    address recipient,
    uint256 amount,
    bytes calldata proof
) external {
    require(!nullifierUsed[nullifier], "NullifierAlreadyUsed");
    
    // Verify ZK proof
    require(
        verifier.verifyProof(proof, [commitment, nullifier, recipient, amount]),
        "InvalidProof"
    );
    
    // Mark nullifier as used
    nullifierUsed[nullifier] = true;
    
    // Transfer funds
    IERC20(weth).transfer(recipient, amount);
    
    emit Withdraw(nullifier, recipient, amount);
}

Further Reading