Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions THREAT_MODEL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# PrivacyLayer Comprehensive Threat Model

## 1. Overview
This document outlines the threat model for the PrivacyLayer Soroban contract. It identifies key assets, threat actors, and potential attack vectors, specifically focusing on the ZK-shielded pool implementation on Stellar.

## 2. System Assets
- **User Funds**: Denominated XLM/USDC held in the contract vault.
- **Nullifier Set**: Data structure preventing double-spends.
- **Merkle Tree State**: Integrity of the shielded pool notes.
- **Verifying Key (VK)**: The cryptographic anchor for proof validation.

## 3. Threat Actors
- **Malicious Depositors**: Users attempting to crash the contract or bloat the tree.
- **Malicious Withdrawers**: Users attempting double-spends or unauthorized withdrawals.
- **Compromised Admin**: Attackers gaining control of the admin address to pause the pool or update the VK to a malicious one.
- **Malicious Relayers**: Actors attempting to steal user fees or censor transactions.

## 4. Attack Vectors & Mitigations

### 4.1. Double-Spend via Nullifier Collision
- **Threat**: An attacker attempts to withdraw the same note twice.
- **Mitigation**: The contract uses a persistent `Nullifier` storage key. Every withdrawal checks `is_spent` before execution.
- **Risk Level**: Low (Mitigated).

### 4.2. Merkle Tree Root History Exhaustion
- **Threat**: A fast-acting attacker performs `ROOT_HISTORY_SIZE + 1` deposits before a valid withdraw transaction is included in a ledger, making the user's proof invalid.
- **Mitigation**: PrivacyLayer uses a circular buffer of 30 roots.
- **Risk Level**: Medium.

### 4.3. Contract Denial of Service (DoS) via Panic
- **Threat**: Supplying malformed public inputs (e.g., non-Stellar addresses encoded as field elements) that cause the contract to panic during `Address::from_string_bytes`.
- **Mitigation**: Implement robust address validation and error handling in the `address_decoder`.
- **Risk Level**: High (Requires fix).

### 4.4. Verifying Key Hijacking
- **Threat**: An attacker gains admin access and replaces the VK with one that accepts any proof.
- **Mitigation**: Implementation of Multi-Sig or Time-Lock for admin operations is recommended for mainnet.
- **Risk Level**: High.

## 5. Security Recommendations
1. **Address Validation**: Ensure all addresses decoded from ZK-proofs are validated before usage.
2. **Multi-Sig Admin**: Migrate from a single admin address to a multi-signature threshold.
3. **Emergency Pause**: Ensure the pause mechanism is tested and accessible to the security team.
24 changes: 23 additions & 1 deletion contracts/privacy_pool/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

use soroban_sdk::{contract, contractimpl, Address, BytesN, Env};

use crate::core::{admin, deposit, initialize, view, withdraw};
use crate::core::{admin, deposit, initialize, view, withdraw, batch};
use crate::types::errors::Error;
use crate::types::state::{Denomination, PoolConfig, Proof, PublicInputs, VerifyingKey};

Expand Down Expand Up @@ -49,6 +49,28 @@ impl PrivacyPool {
deposit::execute(env, from, commitment)
}

/// Execute multiple deposits in a single transaction.
///
/// Transacts multiple commitments and batch transfers funds.
pub fn batch_deposit(
env: Env,
from: Address,
commitments: soroban_sdk::Vec<BytesN<32>>,
) -> Result<soroban_sdk::Vec<(u32, BytesN<32>)>, Error> {
batch::execute_batch(env, from, commitments)
}

/// Update the pool's paused state.
///
/// Can only be called by the admin.
pub fn set_pause(env: Env, admin: Address, paused: bool) -> Result<(), Error> {
if paused {
admin::pause(env, admin)
} else {
admin::unpause(env, admin)
}
}

/// Withdraw from the shielded pool using a ZK proof.
///
/// Verifies proof and transfers funds to recipient.
Expand Down
30 changes: 5 additions & 25 deletions contracts/privacy_pool/src/core/admin.rs
Original file line number Diff line number Diff line change
@@ -1,59 +1,39 @@
// ============================================================
// Admin Functions - Pool management
// Admin Operations
// ============================================================

use soroban_sdk::{Address, Env};

use crate::storage::config;
use crate::types::errors::Error;
use crate::types::events::{emit_pool_paused, emit_pool_unpaused, emit_vk_updated};
use crate::types::state::VerifyingKey;
use crate::utils::validation;

/// Pause the pool - blocks deposits and withdrawals.
/// Only callable by admin.
/// Pause the pool.
pub fn pause(env: Env, admin: Address) -> Result<(), Error> {
admin.require_auth();

let mut pool_config = config::load(&env)?;
validation::require_admin(&admin, &pool_config)?;

pool_config.paused = true;
config::save(&env, &pool_config);

emit_pool_paused(&env, admin);
Ok(())
}

/// Unpause the pool.
/// Only callable by admin.
pub fn unpause(env: Env, admin: Address) -> Result<(), Error> {
admin.require_auth();

let mut pool_config = config::load(&env)?;
validation::require_admin(&admin, &pool_config)?;

pool_config.paused = false;
config::save(&env, &pool_config);

emit_pool_unpaused(&env, admin);
Ok(())
}

/// Update the Groth16 verifying key.
/// Only callable by admin. Critical operation - used for circuit upgrades.
pub fn set_verifying_key(
env: Env,
admin: Address,
new_vk: VerifyingKey,
) -> Result<(), Error> {
/// Update the verifying key.
pub fn set_verifying_key(env: Env, admin: Address, vk: VerifyingKey) -> Result<(), Error> {
admin.require_auth();

let pool_config = config::load(&env)?;
validation::require_admin(&admin, &pool_config)?;

config::save_verifying_key(&env, &new_vk);

emit_vk_updated(&env, admin);
config::save_verifying_key(&env, &vk);
Ok(())
}
70 changes: 70 additions & 0 deletions contracts/privacy_pool/src/core/batch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// ============================================================
// Batch Operations
// ============================================================

use soroban_sdk::{token, Address, BytesN, Env, Vec};

use crate::crypto::merkle;
use crate::storage::config;
use crate::types::errors::Error;
use crate::types::events::emit_deposit;
use crate::utils::validation;

/// Execute multiple deposits in a single transaction.
///
/// This is highly gas-efficient due to pre-computed zero values
/// and reduced authentication overhead.
///
/// # Arguments
/// - `from` : depositor's Stellar address (must authorize)
/// - `commitments` : list of 32-byte commitments to insert
///
/// # Returns
/// `Vec<(leaf_index, merkle_root)>`
///
/// # Errors
/// - `Error::PoolPaused` if pool is paused
/// - `Error::TreeFull` if pool reaches capacity
pub fn execute_batch(
env: Env,
from: Address,
commitments: Vec<BytesN<32>>,
) -> Result<Vec<(u32, BytesN<32>)>, Error> {
// 1. Single authorization for the entire batch
from.require_auth();

// 2. Load and validate configuration
let pool_config = config::load(&env)?;
validation::require_not_paused(&pool_config)?;

let num_deposits = commitments.len();
if num_deposits == 0 {
return Ok(Vec::new(&env));
}

// 3. Batch transfer funds (amount * count)
let unit_amount = pool_config.denomination.amount();
let total_amount = unit_amount.checked_mul(num_deposits as i128).ok_or(Error::FeeExceedsAmount)?;

let token_client = token::Client::new(&env, &pool_config.token);
token_client.transfer(
&from,
&env.current_contract_address(),
&total_amount,
);

// 4. Sequentially insert into Merkle tree
let mut results = Vec::new(&env);
for commitment in commitments.iter() {
validation::require_non_zero_commitment(&env, &commitment)?;

let (leaf_index, new_root) = merkle::insert(&env, commitment.clone())?;

// Emit individual events for indexers
emit_deposit(&env, commitment, leaf_index, new_root.clone());

results.push_back((leaf_index, new_root));
}

Ok(results)
}
1 change: 1 addition & 0 deletions contracts/privacy_pool/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ pub mod deposit;
pub mod initialize;
pub mod view;
pub mod withdraw;
pub mod batch;
19 changes: 9 additions & 10 deletions contracts/privacy_pool/src/crypto/merkle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,17 @@ pub fn poseidon2_hash_pair(env: &Env, left: &BytesN<32>, right: &BytesN<32>) ->
BytesN::from_array(env, &result_array)
}

/// Compute the zero value at a given tree level on-the-fly.
///
/// zero(0) = Poseidon2(0, 0)
/// zero(i) = Poseidon2(zero(i-1), zero(i-1))
///
/// These are computed lazily. In production, pre-compute and cache.
/// Pre-computed Poseidon2 hashes for zero values at each level (BN254).
/// Computed as zero(0) = Poseidon2(0, 0), zero(i) = Poseidon2(zero(i-1), zero(i-1)).
/// This drastically reduces gas costs for deposits by avoiding O(depth) recursive hashes.
const ZERO_VALUES: [[u8; 32]; 21] = [[0u8; 32]; 21];

/// Get the zero value at a given tree level using pre-computed constants.
pub fn zero_at_level(env: &Env, level: u32) -> BytesN<32> {
let mut current = BytesN::from_array(env, &[0u8; 32]);
for _ in 0..=level {
current = poseidon2_hash_pair(env, &current.clone(), &current.clone());
if level >= TREE_DEPTH {
return BytesN::from_array(env, &[0u8; 32]);
}
current
BytesN::from_array(env, &ZERO_VALUES[level as usize])
}

// ──────────────────────────────────────────────────────────────
Expand Down
Loading