Overview
System design of Veil
Veil follows a hybrid architecture to balance developer experience (Python) with performance (Rust).
The user-facing API layer providing high-level primitives for privacy-preserving transactions.
VeilClient
Main entry point for all privacy operations:
Methods:
shield()/shield_async()- Convert public tokens to private commitmentsprivate_transfer()/private_transfer_async()- Transfer privately between notesunshield()/unshield_async()- Withdraw to public addressesverify_proof()- Client-side proof verificationis_nullifier_spent()- Check if a nullifier has been usedget_merkle_root()- Query current on-chain Merkle rootget_commitment_index()- Find leaf index of a commitment
Async vs Sync:
- Async methods (
*_async()): Submit to blockchain, returns transaction signature - Sync methods: Offline computation only, returns transaction data for later submission
- Useful for air-gapped signing and testing scenarios
RelayerClient
IP privacy and gas abstraction layer:
Methods:
add_relayer(url, priority)- Register a relayer endpointadd_default_relayers()- Add known public relayersselect_relayer(strategy)- Choose optimal relayer (lowest_fee, fastest, balanced, random)estimate_fee(transaction)- Get fee quote from relayersubmit_private_transfer(request)- Submit via relayer (hides IP)get_relayer_status(url)- Health check and metrics
Benefits:
- Breaks IP-to-transaction link
- No direct connection to Solana RPC
- Typical fee: 0.3% (30 basis points)
Types Module
Data structures and type definitions:
Note- Private note with amount, blinding, commitmentPrivateTransaction- Transaction result with status, signature, secretsCommitmentData- Commitment manipulation (to_hex, from_hex)EncryptedNote- ECDH-encrypted note dataTransactionStatus- Enum for transaction statesRelayerInfo- Relayer metadata and statsMerkleProof- Merkle path and verification data
Utilities
generate_secret()- Cryptographically secure random secret generationcommitment_to_hex()/hex_to_commitment()- Serialization helpersvalidate_solana_address()- Address validationvalidate_secret()- Secret format validation
Solana Integration
- SolanaClient: Async RPC client for blockchain queries and submissions
- InstructionBuilder: Constructs Anchor instructions for the privacy program
- Account Fetching: Queries on-chain state (pool, nullifiers, Merkle root)
The high-performance cryptographic engine, exposed to Python via PyO3 bindings.
Crypto Module
Pedersen Commitments
C = amount * G + blinding * H
- Curve: BN254 G1 subgroup
- Generators: G (standard), H (nothing-up-my-sleeve construction)
- Properties: Perfectly hiding, computationally binding
- Size: 32 bytes per commitment
Circuit-Safe Nullifier Derivation
spending_key = Poseidon(secret, "NYX_SPENDING_KEY")
nullifier = Poseidon(spending_key, Hash(leaf_index || "NYX_NULLIFIER"))
- Two-step process prevents secret exposure in constraints
- Security: Unlinkable to commitment without knowing secret
- Size: 32 bytes per nullifier
Poseidon Hash Function
- Parameters: t=3 (width), RF=8 (full rounds), RP=57 (partial rounds)
- S-box: x^5 over BN254 scalar field
- Efficiency: Optimized for zkSNARK circuits (~200 constraints per hash)
- Usage: Merkle tree, nullifier derivation, commitment blinding
ECDH Note Encryption
- Key Exchange: ECDH on BN254 G1 curve
- Symmetric Cipher: ChaCha20-Poly1305 AEAD
- Plaintext: 48 bytes (amount + blinding + asset_id)
- Ciphertext: 64 bytes (48 data + 16 MAC) + 32 bytes ephemeral key = 96 bytes total
- Domain Separator: "NYX_NOTE_ENCRYPTION_V1"
- Property: Forward secrecy via ephemeral keys
Merkle Tree
- Depth: 20 levels (supports ~1,048,576 leaves)
- Hash Function: Poseidon
- Optimization: Filled subtrees for O(log n) insertions
- Zero Hashes: Precomputed for empty nodes
- Storage: On-chain state maintained by Solana program
Proof Module
Groth16 zkSNARKs
- Circuit Name:
TransferCircuit - Constraints: ~7,000 R1CS constraints
- Proving Time: 5-10 seconds (client-side, depends on hardware)
- Proof Size: 256 bytes (proof_a: 64, proof_b: 128, proof_c: 64)
- Library:
ark-groth16from arkworks-rs ecosystem
Public Inputs (96 bytes):
merkle_root(32 bytes) - Current Merkle tree rootnullifier(32 bytes) - Nullifier to prevent double-spendnew_commitment(32 bytes) - Output commitment
Private Inputs (Witness):
secret(32 bytes) - Master secretinput_amount,input_blinding- Input note dataoutput_blinding- Output note blinding factormerkle_path(20 × 32 bytes) - Merkle proof siblingsleaf_index- Position in Merkle tree- Asset IDs and metadata
Circuit Constraints Breakdown:
- Spending key derivation: ~400 constraints
- Input commitment verification: ~500 constraints
- Merkle membership proof: ~4,000 constraints (20 Poseidon hashes)
- Nullifier derivation: ~400 constraints
- Output commitment: ~500 constraints
- Range checks and validation: ~1,200 constraints
MVP Mode (Testing Only)
- Proof System: Ed25519 signature-based
- Proof Size: 96 bytes (64 signature + 32 pubkey)
- Proving Time: <1ms (for rapid development)
- Security: NOT production-ready, testing only
- Auto-Detection: On-chain program detects proof length
Bindings Module
PyO3 interface exposing Rust functions to Python:
Exposed Functions:
generate_commitment(amount, secret)→ bytesgenerate_nullifier(spending_key, leaf_index)→ bytesgenerate_proof(witness_json)→ bytes (proof)verify_proof(proof, public_inputs)→ boolposeidon_hash(inputs)→ bytesmerkle_root(leaves)→ bytesencrypt_note(amount, blinding, recipient_pubkey)→ EncryptedNotedecrypt_note(encrypted, secret_key)→Option<Note>
Error Handling:
- Rust errors converted to Python exceptions
- Detailed error messages for debugging
- Type conversions handled automatically
Anchor-based smart contract handling verification and state management.
State Accounts
PrivacyPool Account (~1692 bytes)
pub struct PrivacyPool {
pub authority: Pubkey, // 32 bytes
pub merkle_root: [u8; 32], // 32 bytes
pub leaf_count: u64, // 8 bytes
pub root_history: [MerkleRoot; 30], // 960 bytes (32 × 30)
pub current_root_index: u8, // 1 byte
// ... additional fields
}- PDA Seeds:
[b"privacy_pool"] - Purpose: Main state for the privacy protocol
- Initialization: One-time setup via
initializeinstruction
NullifierMarker Account (~128 bytes each)
pub struct NullifierMarker {
pub pool: Pubkey, // 32 bytes
pub nullifier: [u8; 32], // 32 bytes
pub spent_at: u64, // 8 bytes (slot number)
}- PDA Seeds:
[b"nullifier", pool.key(), &nullifier] - Purpose: Marks a nullifier as spent (double-spend prevention)
- Creation: Anchor
initconstraint ensures uniqueness
Instructions
1. Initialize
- Creates the privacy pool
- Sets authority and initial Merkle root
- One-time operation per deployment
2. Shield / ShieldSOL
- Deposits public tokens → creates private commitment
- Adds commitment to Merkle tree
- Transfers tokens to pool vault
3. Transfer
- Inputs: Proof, nullifier, new commitment, encrypted note
- Verification: zkSNARK proof via
groth16-solana - Checks: Nullifier not spent, Merkle root in history
- Actions: Create nullifier PDA, add commitment, update root history
4. Unshield / UnshieldSOL
- Similar to transfer but outputs to public address
- Withdraws tokens from pool vault to recipient
5. UpdateRelayer (Future)
- Manages relayer registry
- Sets fee limits and permissions
6. WithdrawFees (Future)
- Allows relayers to claim accumulated fees
Groth16 Verification
Integration:
- Uses
groth16-solanacrate for on-chain verification - Verification cost: ~200,000 compute units
- Verification time: <1 second
Process:
- Deserialize proof (256 bytes) and public inputs (96 bytes)
- Perform BN254 pairing checks
- Verify equation:
e(A, B) = e(α, β) · e(C, δ) · e(public_inputs, γ) - Accept transaction if verification passes
Proof Auto-Detection:
if proof.len() == 256 {
verify_groth16(proof, public_inputs)
} else if proof.len() == 96 {
verify_mvp(proof, public_inputs) // Dev mode only
} else {
return Err(InvalidProofSize)
}Nullifier Tracking
PDA-Based Approach:
- Each nullifier gets its own PDA account
- PDA derivation:
[b"nullifier", pool_id, nullifier_bytes] - Anchor's
initconstraint prevents duplicate creation - Attempting to reuse a nullifier fails automatically
Benefits:
- O(1) lookup via PDA derivation (no iteration)
- Efficient storage (~0.00089 SOL rent per nullifier)
- Cryptographically guaranteed uniqueness
- Relayer pays rent (incentivizes efficient gas usage)
Root History Buffer
Purpose: Front-running protection
Implementation:
root_history: [MerkleRoot; 30]
current_root_index: u8 // Circular buffer indexOperation:
- When Merkle tree updates, new root added to buffer
- Old root (>30 updates ago) is overwritten
- Proofs valid for any root in history window
- ~1 minute validity window (30 roots × 2 sec/block)
Benefits:
- Users have time to submit proof after generation
- Concurrent transactions don't invalidate each other's proofs
- No race conditions during proof generation period
Compute Unit Analysis
| Operation | CU Cost | Notes |
|---|---|---|
| Shield | ~50,000 | Simple addition to Merkle tree |
| Transfer | ~200,000 | Includes Groth16 verification |
| Unshield | ~150,000 | Verification + token transfer |
| Merkle Update | ~10,000 | Hash computation for path |
| Nullifier Check | ~5,000 | PDA derivation and lookup |
Total Transaction Cost:
- Transfer: 200k CU ≈ 0.0002 SOL (at 5,000 lamports/CU)
- Plus Solana base fee: ~0.000005 SOL
- Plus relayer fee (if used): ~0.3% of amount
Client-Side (Python + Rust)
| Operation | Time | RAM | CPU |
|---|---|---|---|
| Generate Secret | <1ms | Minimal | Minimal |
| Create Commitment | <10ms | <10 MB | 1 core |
| Generate Proof | 5-10s | 2-4 GB | Multi-core (beneficial) |
| Verify Proof (local) | <100ms | <100 MB | 1 core |
| Encrypt Note | <5ms | Minimal | Minimal |
| Scan 1000 Notes | ~5s | <50 MB | 1 core |
On-Chain (Solana)
| Metric | Value |
|---|---|
| Proof Verification | ~200k CU (~0.0002 SOL) |
| Block Time | ~400-600ms (Solana average) |
| Confirmation | ~2-3 seconds (finalized) |
| Throughput | Limited by Solana TPS (~3,000/s) |
Scalability
- Merkle Tree: Depth 20 supports ~1M leaves
- Concurrent Users: Unlimited (stateless proofs)
- Transaction Ordering: Root history allows parallel submissions
- Storage Growth: ~128 bytes per nullifier (bounded by leaves)
Assumptions:
- Discrete Log: BN254 discrete log is hard
- Trusted Setup: At least one MPC participant is honest
- Solana Security: Blockchain provides integrity and availability
- Randomness:
OsRngprovides secure randomness
Guarantees:
- Amount privacy (information-theoretic)
- Sender/recipient unlinkability (computational)
- Double-spend prevention (protocol-enforced)
- Front-running protection (30-root buffer)
Limitations:
- Timing analysis at shield/unshield boundaries
- IP exposure without relayers
- Trusted setup required (MPC ceremony in Phase 4)
See also:
- Data Flow - Detailed transaction flows
- Solana Program - On-chain implementation details
- Privacy Model - Privacy guarantees and limitations
On This Page
- System Architecture
- Layer 1: Python SDK (`veil`)
- VeilClient
- RelayerClient
- Types Module
- Utilities
- Solana Integration
- Layer 2: Rust Core (`veil-core`)
- Crypto Module
- Proof Module
- Bindings Module
- Layer 3: Solana On-Chain Program
- State Accounts
- Instructions
- Groth16 Verification
- Nullifier Tracking
- Root History Buffer
- Compute Unit Analysis
- Performance Characteristics
- Client-Side (Python + Rust)
- On-Chain (Solana)
- Scalability
- Security Model