Data Flow
Detailed transaction flows and cryptographic operations
This document provides step-by-step breakdowns of how data flows through Veil's privacy protocol for each operation type.
Converting public tokens into private commitments.
Client-Side Operations
Step 1: User Initiates Shield
client.shield_async(
amount=1_000_000_000, # 1 SOL
token="SOL",
keypair=user_keypair
)Step 2: Generate Blinding Factor
- SDK generates cryptographically secure random blinding (32 bytes)
- Uses
OsRngfrom Rust for entropy - Blinding factor is stored locally, never leaves the device
Step 3: Compute Commitment
C = amount * G + blinding * H
- Performed in Rust core via PyO3
- Uses BN254 G1 curve operations
- Result: 32-byte commitment (compressed curve point)
Step 4: Build Transaction
- Construct Anchor instruction for
shieldorshield_sol - Include commitment in instruction data
- Attach token transfer (public → pool vault)
- Sign with user's keypair
Step 5: Submit to Solana
[User] --HTTPS--> [Solana RPC] ---> [Blockchain]
- Direct submission (IP visible) or via relayer (IP hidden)
- Transaction broadcast to validators
On-Chain Operations
Step 6: Program Receives Transaction
- Solana runtime deserializes instruction data
- Calls
shieldhandler in Anchor program
Step 7: Token Transfer Verification
- Verify tokens transferred from user to vault
- Check amount matches instruction parameter
- Ensure vault authority is correct PDA
Step 8: Commitment Insertion
let new_leaf_index = pool.leaf_count;
pool.commitments[new_leaf_index] = commitment;
pool.leaf_count += 1;Step 9: Merkle Tree Update
new_root = update_merkle_tree(commitment, new_leaf_index)
- Compute new Merkle root with added commitment
- Uses Poseidon hash for path computation
- ~10 hash operations for depth-20 tree
Step 10: Update Root History
pool.root_history[pool.current_root_index] = new_root;
pool.current_root_index = (pool.current_root_index + 1) % 30;
pool.merkle_root = new_root;Step 11: Emit Events (optional)
- Log commitment added event
- Include leaf index for off-chain indexing
Client-Side Post-Processing
Step 12: Store Note Data
note = Note(
commitment=C,
amount=1_000_000_000,
blinding=blinding_factor,
leaf_index=new_leaf_index,
asset_id=0 # SOL
)
save_note_locally(note)Total Time: ~3-5 seconds (Solana finality)
On-Chain Visibility:
- Commitment (32 bytes, appears random)
- Token transfer (amount and sender visible)
- Leaf index (sequential counter)
Private Data:
amount(only user knows)blinding(only user knows)
Spending a note to create a new one for a recipient.
Client-Side Operations
Step 1: User Initiates Transfer
client.private_transfer_async(
input_note=my_note,
recipient_pubkey="0x1234...",
amount=1_000_000_000
)Step 2: Retrieve Merkle Proof
- Query Solana for current Merkle tree state
- Fetch Merkle path (sibling hashes) for
input_note.leaf_index - Verify current root matches on-chain state
Step 3: Derive Spending Key
spending_key = Poseidon(secret, "NYX_SPENDING_KEY")
- First step of circuit-safe nullifier derivation
- Performed in Rust core
Step 4: Compute Nullifier
nullifier = Poseidon(spending_key, Hash(leaf_index || "NYX_NULLIFIER"))
- Second step ensures circuit safety
- Result: 32-byte nullifier (appears random)
Step 5: Generate Output Blinding
output_blinding = random_32_bytes()
- New random blinding for recipient's commitment
- Ensures unlinkability
Step 6: Compute Output Commitment
output_commitment = amount * G + output_blinding * H
Step 7: Encrypt Note for Recipient
encrypted_note = ECDH_encrypt(
amount=amount,
blinding=output_blinding,
asset_id=0,
recipient_pubkey=recipient_pubkey
)
- Generate ephemeral keypair
- Compute shared secret via ECDH
- Encrypt with ChaCha20-Poly1305
- Result: 96 bytes (32 ephemeral_pubkey + 64 ciphertext+MAC)
Step 8: Construct Witness
{
"secret": "0x...",
"input_amount": 1000000000,
"input_blinding": "0x...",
"output_blinding": "0x...",
"merkle_path": ["0x...", "0x...", ...], // 20 siblings
"leaf_index": 42,
"asset_id": 0
}Step 9: Generate zkSNARK Proof ⏱️ 5-10 seconds
proof = generate_groth16_proof(witness)
- Most computationally expensive step
- ~7,000 R1CS constraint evaluation
- Performed in Rust via ark-groth16
- Proves:
- Input commitment exists in Merkle tree
- Nullifier correctly derived
- Output commitment preserves amount
- All cryptographic operations valid
Proof Components:
proof_a(64 bytes) - G1 pointproof_b(128 bytes) - G2 pointproof_c(64 bytes) - G1 point- Total: 256 bytes
Step 10: Build Transaction
TransferInstruction {
proof: [u8; 256],
nullifier: [u8; 32],
new_commitment: [u8; 32],
encrypted_note: [u8; 96],
merkle_root: [u8; 32],
}Step 11: Submit Transaction
- Option A: Direct submission (IP visible)
tx_sig = client.send_transaction(tx) - Option B: Via relayer (IP hidden)
result = relayer_client.submit_private_transfer( proof=proof, max_fee=estimated_fee )
On-Chain Operations
Step 12: Program Receives Transaction
- Deserialize instruction data
- Extract proof, nullifier, commitment, encrypted note
Step 13: Verify Merkle Root
let root_valid = pool.root_history.contains(&instruction.merkle_root);
require!(root_valid, ErrorCode::InvalidMerkleRoot);- Check if provided root is in 30-root history
- Protects against front-running
Step 14: Verify zkSNARK Proof ⚠️ ~200k compute units
let public_inputs = [
instruction.merkle_root,
instruction.nullifier,
instruction.new_commitment,
];
verify_groth16(instruction.proof, public_inputs)?;- Performs BN254 pairing checks
- Verifies all circuit constraints satisfied
- Most expensive on-chain operation
Step 15: Check Nullifier Not Spent
let nullifier_pda = Pubkey::find_program_address(
&[b"nullifier", pool.key().as_ref(), &instruction.nullifier],
program_id
);
// Anchor's `init` constraint ensures this PDA doesn't exist yetStep 16: Create Nullifier Marker
#[account(
init,
seeds = [b"nullifier", pool.key().as_ref(), nullifier.as_ref()],
payer = relayer,
space = 8 + 128
)]
pub nullifier_marker: Account<'info, NullifierMarker>- Creates PDA account (rent: ~0.00089 SOL)
- Marks nullifier as spent
- Second attempt to use same nullifier will fail
Step 17: Add New Commitment to Merkle Tree
let new_leaf_index = pool.leaf_count;
pool.commitments[new_leaf_index] = instruction.new_commitment;
pool.leaf_count += 1;Step 18: Update Merkle Root
let new_root = compute_merkle_root_with_new_leaf(
instruction.new_commitment,
new_leaf_index
);Step 19: Update Root History
pool.root_history[pool.current_root_index] = new_root;
pool.current_root_index = (pool.current_root_index + 1) % 30;
pool.merkle_root = new_root;Step 20: Store Encrypted Note (optional)
- Store in transaction logs for recipient discovery
- Or in separate account (higher cost)
Step 21: Pay Relayer Fee (if used)
if let Some(relayer) = accounts.relayer {
let fee = calculate_fee(amount, relayer.fee_rate);
transfer_sol(pool_vault, relayer, fee)?;
}Recipient Discovery
Step 22: Recipient Scans Blockchain
encrypted_notes = scan_recent_transactions(recipient_secret_key)Step 23: Trial Decryption
- For each encrypted note found:
- Compute shared secret using recipient's secret key
- Attempt ChaCha20-Poly1305 decryption
- If MAC verifies → note belongs to recipient
Step 24: Recipient Stores Note
note = Note(
commitment=new_commitment,
amount=decrypted_amount,
blinding=decrypted_blinding,
leaf_index=new_leaf_index,
asset_id=decrypted_asset_id
)
save_note_locally(note)Total Time:
- Proof generation: 5-10 seconds
- On-chain confirmation: ~3 seconds
- Total: 8-13 seconds
On-Chain Visibility:
- Nullifier (32 bytes, appears random)
- New commitment (32 bytes, appears random)
- Encrypted note (96 bytes, appears random)
- zkSNARK proof (256 bytes)
Private Data:
- Sender identity (unlinkable via nullifier)
- Recipient identity (requires trial decryption)
- Amount (hidden in commitments)
Spending a note to withdraw to a public address.
Client-Side Operations
Step 1: User Initiates Unshield
client.unshield_async(
input_note=my_note,
recipient_address="9x4Fz...",
amount=1_000_000_000
)Steps 2-9: Similar to Private Transfer
- Retrieve Merkle proof
- Derive spending key
- Compute nullifier
- Construct witness (no output commitment needed)
- Generate zkSNARK proof (~5-10 seconds)
Step 10: Build Transaction
UnshieldInstruction {
proof: [u8; 256],
nullifier: [u8; 32],
recipient: Pubkey,
amount: u64,
merkle_root: [u8; 32],
}- Note: Amount and recipient are PUBLIC in unshield
Step 11: Submit Transaction
On-Chain Operations
Step 12-16: Similar to Private Transfer
- Verify Merkle root in history
- Verify zkSNARK proof (~150k CU, slightly less than transfer)
- Check nullifier not spent
- Create nullifier marker PDA
Step 17: Transfer Tokens to Recipient
transfer_from_vault(
pool_vault,
recipient,
amount
)?;- Uses vault authority PDA as signer
- Transfers SOL or SPL tokens to public recipient address
Step 18: Update State
- Update root history (no new commitment added)
- Emit withdrawal event
Total Time: 8-13 seconds (similar to private transfer)
On-Chain Visibility:
- Nullifier (32 bytes, appears random)
- Recipient address (PUBLIC)
- Amount (PUBLIC)
- zkSNARK proof (256 bytes)
Privacy Trade-offs:
- Reveals withdrawal amount and destination
- Does NOT reveal sender (nullifier is unlinkable)
- Does NOT reveal deposit (no link to original shield operation)
Using relayers for IP privacy.
Modified Client-Side Flow
Step 1-9: Standard Proof Generation
- Same as direct submission
- Generate proof, nullifier, commitment locally
Step 10: Select Relayer
relayer = relayer_client.select_relayer(strategy="balanced")- Chooses based on fees, reliability, location
Step 11: Estimate Fee
fee = relayer_client.estimate_fee(transaction)
# Returns: 0.003 SOL (0.3% of 1 SOL transfer)Step 12: Sign Transaction
signed_tx = sign_transaction(transaction, user_keypair)- User signs entire transaction
- Relayer cannot modify without invalidating signature
Step 13: Submit to Relayer 🔒 (via Tor/VPN for max privacy)
[User] --HTTPS/Tor--> [Relayer]
- User's IP hidden from blockchain
- Relayer sees transaction data but not user IP (if using Tor)
Relayer Operations
Step 14: Relayer Validates
- Check transaction format
- Verify signature
- Estimate gas cost
Step 15: Relayer Adds Fee
transaction.instructions.push(
transfer_fee_to_relayer(fee_amount)
);Step 16: Relayer Submits
[Relayer] --RPC--> [Solana Blockchain]
- Uses relayer's infrastructure and IP
- User's IP never exposed to blockchain
On-Chain Operations
Step 17: Standard Verification
- Same as direct submission
- Proof verified, nullifier checked, commitment added
Step 18: Fee Collection
transfer_sol(pool_vault, relayer_address, fee)?;- Relayer compensated automatically
- Fee deducted from transaction
Benefits:
- ✅ User IP hidden from RPC providers
- ✅ No direct connection to blockchain
- ✅ Censorship resistance (multiple relayers)
Cost:
- ~0.3% fee (30 basis points)
How recipients find their notes on the blockchain.
Step 1: Fetch Recent Transactions
transactions = rpc.get_signatures_for_address(
privacy_program_id,
limit=1000
)Step 2: Filter for Private Transfers
transfers = [
tx for tx in transactions
if tx.instruction_name == "private_transfer"
]Step 3: Extract Encrypted Notes
encrypted_notes = []
for tx in transfers:
encrypted_note = parse_encrypted_note_from_logs(tx)
encrypted_notes.append(encrypted_note)Step 4: Trial Decryption (Parallel)
my_notes = []
for enc_note in encrypted_notes:
shared_secret = recipient_secret_key * enc_note.ephemeral_pubkey
try:
plaintext = decrypt_chacha20poly1305(
enc_note.ciphertext,
shared_secret
)
# Success! This note belongs to us
note = parse_note(plaintext)
my_notes.append(note)
except AuthenticationError:
# Not our note, skip
continueStep 5: Verify Commitment
for note in my_notes:
computed_commitment = note.amount * G + note.blinding * H
assert computed_commitment == note.commitment # Sanity checkStep 6: Store in Local Database
for note in my_notes:
db.store_note(
commitment=note.commitment,
amount=note.amount,
blinding=note.blinding,
leaf_index=note.leaf_index,
discovered_at=now()
)Optimization: Incremental Scanning
last_scanned_slot = db.get_last_scanned_slot()
new_transactions = rpc.get_transactions_since(last_scanned_slot)
# Only scan new transactions
db.set_last_scanned_slot(current_slot)Performance:
- 1,000 notes: ~5 seconds (trial decryption is fast)
- 10,000 notes: ~50 seconds
- Can be parallelized across CPU cores
| Operation | Client Time | On-Chain CU | On-Chain Data | Privacy |
|---|---|---|---|---|
| Shield | <1s | ~50k | Commitment (32B) | Amount hidden |
| Transfer | 5-10s | ~200k | Nullifier + Commitment + Note (160B) | Full privacy |
| Unshield | 5-10s | ~150k | Nullifier + Amount + Recipient | Sender hidden |
| Relayer Transfer | 5-10s | ~200k | Same as transfer | + IP privacy |
| Note Scan | ~5s/1000 notes | N/A | Read-only | Passive |
See also:
- Architecture Overview - System components and design
- Solana Program - On-chain implementation details
- Privacy Model - Privacy guarantees
On This Page
- Data Flow
- Shield Flow (Deposit)
- Client-Side Operations
- On-Chain Operations
- Client-Side Post-Processing
- Private Transfer Flow
- Client-Side Operations
- On-Chain Operations
- Recipient Discovery
- Unshield Flow (Withdrawal)
- Client-Side Operations
- On-Chain Operations
- Relayer-Mediated Flow
- Modified Client-Side Flow
- Relayer Operations
- On-Chain Operations
- Note Discovery Flow
- Data Flow Summary