Docs
/architecture
/Data Flow

Data Flow

Detailed transaction flows and cryptographic operations

Data Flow

This document provides step-by-step breakdowns of how data flows through Veil's privacy protocol for each operation type.

Shield Flow (Deposit)

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 OsRng from 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 shield or shield_sol
  • Include commitment CC 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 shield handler 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 CC (32 bytes, appears random)
  • Token transfer (amount and sender visible)
  • Leaf index (sequential counter)

Private Data:

  • amount (only user knows)
  • blinding (only user knows)

Private Transfer Flow

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 point
  • proof_b (128 bytes) - G2 point
  • proof_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 yet

Step 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)

Unshield Flow (Withdrawal)

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)

Relayer-Mediated Flow

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)

Note Discovery Flow

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
        continue

Step 5: Verify Commitment

for note in my_notes:
    computed_commitment = note.amount * G + note.blinding * H
    assert computed_commitment == note.commitment  # Sanity check

Step 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

Data Flow Summary

OperationClient TimeOn-Chain CUOn-Chain DataPrivacy
Shield<1s~50kCommitment (32B)Amount hidden
Transfer5-10s~200kNullifier + Commitment + Note (160B)Full privacy
Unshield5-10s~150kNullifier + Amount + RecipientSender hidden
Relayer Transfer5-10s~200kSame as transfer+ IP privacy
Note Scan~5s/1000 notesN/ARead-onlyPassive

See also: