Docs
/concepts
/Note Encryption

Note Encryption

ECDH-based recipient discovery system

Note Encryption

When you send a private transfer in Veil, the recipient needs to discover that they've received funds. Since all commitments in the Merkle tree look random, Veil uses ECDH-based note encryption to enable recipients to identify and decrypt notes intended for them.

The Recipient Discovery Problem

In a privacy-preserving system:

  • All commitments appear as random 32-byte values on-chain
  • No addresses or identifiers are attached to commitments
  • Recipients need a way to determine which commitments belong to them

The Challenge: How can a recipient discover their notes without compromising privacy?

The Solution: Encrypt note metadata using the recipient's public key, allowing only them to decrypt and claim their funds.

ECDH Key Exchange

Veil uses Elliptic Curve Diffie-Hellman (ECDH) on the BN254 G1 curve for key exchange:

Key Generation

Recipient's Long-Term Keypair:

recipient_secret_key = random 32 bytes
recipient_public_key = recipient_secret_key * G  (point on BN254 G1)

The recipient shares their public key with potential senders (like sharing a payment address).

Encryption Process (Sender)

When sending a private transfer:

  1. Generate Ephemeral Keypair:

    ephemeral_secret = random 32 bytes
    ephemeral_public = ephemeral_secret * G
    
  2. Compute Shared Secret:

    shared_secret = ephemeral_secret * recipient_public_key
                  = ephemeral_secret * (recipient_secret_key * G)
    
  3. Derive Encryption Key:

    encryption_key = KDF(shared_secret, "NYX_NOTE_ENCRYPTION_V1")
    
  4. Encrypt Note Data:

    • Note plaintext: amount (8 bytes) || blinding (32 bytes) || asset_id (8 bytes) = 48 bytes
    • Encrypted using ChaCha20-Poly1305 AEAD
    • Includes 16-byte authentication tag
  5. Publish On-Chain:

    EncryptedNote {
        ephemeral_public_key: 32 bytes,
        ciphertext: 64 bytes (48 data + 16 MAC),
        total: 96 bytes
    }
    

Decryption Process (Recipient)

The recipient scans the blockchain for potential notes:

  1. Compute Shared Secret:

    shared_secret = recipient_secret_key * ephemeral_public_key
                  = recipient_secret_key * (ephemeral_secret * G)
    

    (Same shared secret as sender computed!)

  2. Derive Decryption Key:

    decryption_key = KDF(shared_secret, "NYX_NOTE_ENCRYPTION_V1")
    
  3. Attempt Decryption:

    • Try to decrypt the ciphertext with ChaCha20-Poly1305
    • If authentication succeeds → this note belongs to the recipient
    • If authentication fails → note belongs to someone else
  4. Extract Note Data:

    amount = data[0..8]
    blinding = data[8..40]
    asset_id = data[40..48]
    
  5. Reconstruct Commitment:

    commitment = amount * G + blinding * H
    

    Verify this matches the on-chain commitment.

ChaCha20-Poly1305 AEAD

Veil uses ChaCha20-Poly1305, an Authenticated Encryption with Associated Data (AEAD) cipher:

Why ChaCha20-Poly1305?

  • Fast software implementation (no AES hardware needed)
  • Authenticated encryption (prevents tampering)
  • Well-studied and widely deployed (TLS 1.3, WireGuard)
  • 256-bit security

Encryption:

nonce = random 12 bytes (or derived from ephemeral_public_key)
ciphertext || tag = ChaCha20Poly1305.encrypt(
    key=encryption_key,
    nonce=nonce,
    plaintext=note_data,
    associated_data=""
)

Decryption:

plaintext = ChaCha20Poly1305.decrypt(
    key=decryption_key,
    nonce=nonce,
    ciphertext=ciphertext,
    tag=tag,
    associated_data=""
)
// Returns plaintext if tag is valid, error otherwise

Note Format Specification

Plaintext Note (48 bytes)

Offset  | Size | Field      | Description
--------|------|------------|----------------------------------
0       | 8    | amount     | Amount in lamports (little-endian)
8       | 32   | blinding   | Blinding factor for commitment
40      | 8    | asset_id   | Token identifier (0 = SOL)

Encrypted Note (96 bytes)

Offset  | Size | Field              | Description
--------|------|--------------------|---------------------------
0       | 32   | ephemeral_public   | Ephemeral ECDH public key
32      | 48   | ciphertext         | Encrypted note data
80      | 16   | authentication_tag | Poly1305 MAC

Recipient Note Scanning

Recipients must scan the blockchain to discover their notes:

Scanning Process

  1. Fetch Recent Transactions:

    • Query Solana RPC for transactions to the privacy program
    • Filter for private_transfer instructions
  2. Extract Encrypted Notes:

    • Parse transaction data for EncryptedNote structures
  3. Trial Decryption:

    • For each encrypted note:
      • Compute shared secret using recipient's secret key
      • Attempt decryption
      • If successful → note belongs to recipient
      • If failed → skip to next note
  4. Store Discovered Notes:

    • Save amount, blinding, leaf_index, commitment
    • Track in local database for later spending

Optimization Strategies

Bloom Filters:

  • Recipients can use Bloom filters to quickly skip unlikely notes
  • Reduces trial decryption attempts by ~99%

Batch Scanning:

  • Scan multiple blocks at once
  • Process encrypted notes in parallel

Light Clients:

  • Delegate scanning to a server (with privacy trade-offs)
  • Server cannot decrypt notes but can assist in filtering

Index by Block Height:

  • Only scan new blocks since last check
  • Maintain "last scanned block" pointer

Security Properties

1. Confidentiality

Property: Only the recipient can read the note data.

Mechanism: ECDH ensures only parties knowing either ephemeral_secret or recipient_secret_key can derive the shared secret. ChaCha20 provides semantic security.

2. Authenticity

Property: Recipients can verify the note was created by someone knowing the correct shared secret.

Mechanism: Poly1305 MAC provides authentication. If the tag verifies, the note data hasn't been tampered with.

3. Forward Secrecy

Property: Compromising the recipient's long-term key doesn't reveal past notes.

Mechanism: Each note uses a unique ephemeral key. Even if recipient_secret_key is compromised later, past shared secrets cannot be recomputed without the ephemeral secrets (which should be deleted after encryption).

4. Deniability

Property: The sender can plausibly deny creating a specific encrypted note.

Mechanism: The ephemeral key is generated randomly for each note. There's no signature or proof binding the sender to the encrypted note.

Privacy Considerations

What Encrypted Notes Reveal

Public On-Chain:

  • Ephemeral public key (32 bytes, appears random)
  • Ciphertext (64 bytes, appears random)
  • Existence of a note (meta-data: "someone sent a note")

Hidden:

  • Amount
  • Blinding factor
  • Sender identity
  • Recipient identity (requires trial decryption to determine)

Scanning Privacy

Risk: Scanning the blockchain reveals information about your activity pattern.

Mitigation:

  • Use Tor/VPN when querying RPC nodes
  • Delegate to privacy-preserving light clients
  • Scan from multiple IPs to avoid correlation
  • Batch requests to minimize query patterns

Note Tagging Attacks

Attack: An adversary creates fake encrypted notes with predictable patterns to tag users.

Mitigation:

  • Recipients verify commitment on-chain matches decrypted data
  • Malformed notes are rejected during verification
  • Proper authentication prevents tampering

Usage Example

Encrypting a Note (Sender)

from veil import VeilClient, encrypt_note_for_recipient
 
client = VeilClient()
 
# Recipient shares their public key (e.g., via social profile)
recipient_pubkey = "0x1234...5678"
 
# Create encrypted note for recipient
encrypted_note = encrypt_note_for_recipient(
    amount=1_000_000_000,  # 1 SOL
    recipient_pubkey=recipient_pubkey,
    asset_id=0  # SOL
)
 
# Submit in private_transfer transaction
tx_signature = client.private_transfer(
    input_note=my_note,
    encrypted_note=encrypted_note
)

Scanning for Notes (Recipient)

from veil import VeilClient, decrypt_note
 
client = VeilClient()
my_secret_key = "your_secret_key_here"
 
# Scan recent transactions
recent_notes = client.scan_notes(
    secret_key=my_secret_key,
    start_slot=last_scanned_slot,
    end_slot=current_slot
)
 
for note in recent_notes:
    print(f"Received: {note.amount} lamports")
    print(f"Commitment: {note.commitment}")
    print(f"Leaf index: {note.leaf_index}")
    # Store note for later spending
    save_note_to_database(note)

Future Improvements

  1. Stealth Addresses: One-time addresses generated per transaction for enhanced privacy
  2. Payment Codes: BIP47-style reusable payment codes to avoid sharing long-term public keys
  3. View Keys: Separate keys for scanning vs spending (auditing without spending permission)
  4. Hierarchical Scanning: Derive scanning keys from master seed for multi-device support

See also: