SDK Modifications
Velum's privacy layer is powered by a forked version of the Privacy Cash SDK. This page documents the key modifications that enable fully private paylinks — allowing third-party deposits where only the recipient can claim funds.
Overview
The official Privacy Cash SDK was designed for self-deposits only: a user deposits funds into a shielded pool and later withdraws them. Velum extends this to support paylinks — shareable URLs where anyone can deposit funds that only a designated recipient can claim.
Summary of Changes
| # | Modification | Category |
|---|---|---|
| 1 | Wallet-Adapter Constructor | API Change |
| 2 | Asymmetric Encryption (V3) | New Feature |
| 3 | Pubkey-Only UTXO Mode | New Feature |
| 4 | Third-Party Deposits | New Feature |
| 5 | Recipient Key Retrieval | New Feature |
| 6 | Recipient ID Hash | Optimization |
| 7 | Schema Versioning | Format Evolution |
| 8-10 | Debug Logging & Error Handling | Diagnostics |
| 11 | Browser Compatibility | Environment |
| 12 | Compact Encryption Headers | Optimization |
| 13 | Binary UTXO V2 Encoding | Optimization |
1. Wallet-Adapter Constructor
Problem
The official SDK requires a Solana Keypair (private key) for initialization — incompatible with browser wallet extensions (Phantom, Solflare) that never expose private keys.
1// Requires private key — impossible in browser wallets2constructor(rpcUrl: string, keypair: string | Uint8Array | number[])1// Wallet-adapter mode — works with browser extensions2constructor({3RPC_url: string,4publicKey?: PublicKey, // From wallet.publicKey5signature?: Uint8Array, // From wallet.signMessage()6transactionSigner?: (tx) => Promise<VersionedTransaction>,7circuitPath?: string, // URL path to circuit files8// ... optional debug/storage params9})Usage
1const sdk = new PrivacyCash({2RPC_url: rpcUrl,3publicKey: wallet.publicKey,4signature: await wallet.signMessage("Privacy Money account sign in"),5transactionSigner: async (tx) => wallet.signTransaction(tx),6circuitPath: "/circuit"7});2. Asymmetric Encryption (V3)
Problem
The official SDK only supports symmetric encryption (V1/V2) — only the depositor can decrypt UTXO data. For paylinks, a sender deposits for a recipient who needs to decrypt.
Solution
Added NaCl Box (X25519 + XSalsa20-Poly1305) encryption:
1// Sender encrypts for recipient (anyone can call with recipient's pubkey)2encryptAsymmetric(data: Buffer, recipientPublicKey: Uint8Array): Buffer34// Recipient decrypts (requires their secret key)5private decryptV3(encryptedData: Buffer): Buffer | null67// Key derivation (from wallet signature)8const v2Key = keccak256(signature);9const seed = sha256(v2Key);10const x25519Keypair = nacl.box.keyPair.fromSecretKey(seed);11// .publicKey → shared in paylink12// .secretKey → kept private, used to decryptEach V3 encryption generates an ephemeral keypair (use-once). This provides forward secrecy — even if the recipient's key is later compromised, past deposits remain secure.
3. Pubkey-Only UTXO Mode
Problem
The official SDK requires a full Keypair to create a UTXO. For paylink deposits, the sender only knows the recipient's public key.
Solution
1// Official: requires full keypair2new Utxo({ lightWasm, amount, keypair: fullKeypair })34// Fork: accepts public key alone5new Utxo({ lightWasm, amount, publicKey: recipientBN254Pubkey })67// Behavior:8// • getCommitment() → works (only needs pubkey)9// • getNullifier() → throws (requires private key)10// → Only the recipient can spend this UTXO| Operation | Pubkey-Only | Full Keypair |
|---|---|---|
| Create commitment | Yes | Yes |
| Store on-chain | Yes | Yes |
| Compute nullifier | No | Yes |
| Spend UTXO | No | Yes |
4. Third-Party Deposits
Problem
The official deposit() always creates output UTXOs for the depositor (consolidation pattern). Paylinks need to create UTXOs for a different recipient.
Solution
1deposit({2// ... standard params (amount, connection, etc.)34// NEW: Optional recipient parameters5recipientUtxoPublicKey?: BN | string, // Recipient's BN254 pubkey6recipientEncryptionKey?: Uint8Array // Recipient's X25519 pubkey7})Logic Changes
1// 1. Skip UTXO fetch for third-party deposits2if (!recipientUtxoPublicKey) {3existingUtxos = await getUtxos(...); // Self: consolidate existing4} else {5// Third-party: fresh deposit only, no consolidation6}78// 2. Create output UTXO with correct ownership9if (recipientUtxoPublicKey) {10output = new Utxo({ publicKey: recipientUtxoPublicKey }); // Pubkey-only11} else {12output = new Utxo({ keypair: senderKeypair }); // Self13}1415// 3. Encrypt with correct scheme16if (recipientEncryptionKey) {17encrypted = encryptAsymmetric(data, recipientEncryptionKey); // V318} else {19encrypted = encrypt(data); // V2 (symmetric, self)20}Both recipientUtxoPublicKey and recipientEncryptionKey must be provided together. The first determines UTXO ownership; the second determines who can decrypt the note.
5. Recipient Key Retrieval
Problem
Paylinks need to encode the recipient's cryptographic keys. The official SDK doesn't expose them.
Solution
1// X25519 public key (for V3 asymmetric encryption)2sdk.getAsymmetricPublicKey(): Uint8Array // 32 bytes34// BN254 public key (for UTXO ownership)5await sdk.getShieldedPublicKey(): Promise<string> // Decimal string67// Usage in paylink creation:8const paylink = await fetch('/api/paylinks', {9method: 'POST',10body: JSON.stringify({11 recipientEncryptionKey: base64(sdk.getAsymmetricPublicKey()),12 recipientUtxoPubkey: await sdk.getShieldedPublicKey(),13 token: 'SOL'14})15});6. Recipient ID Hash (Early Termination)
Problem
Balance scanning requires attempting decryption on all UTXOs in the pool. With thousands of UTXOs, this takes tens of seconds.
Solution
Added an 8-byte recipientIdHash to the wire format for O(1) pre-filtering:
1// Hash derivation2recipientIdHash = SHA256(x25519PublicKey)[0:8] // First 8 bytes34// Fast pre-filter (before expensive crypto)5shouldAttemptDecryption(buffer: Buffer): boolean {6const storedHash = buffer.subarray(9, 17); // From encrypted blob7const myHash = this.deriveRecipientIdHash(); // From my key8return storedHash.equals(myHash); // 8-byte compare9}1011// Performance:12// Before: 50,000 × decrypt attempt = ~30 seconds13// After: 50,000 × hash compare + ~5 × decrypt = ~0.5 seconds7-10. Supporting Modifications
Schema Versioning (#7)
A schema byte at offset 8 enables format evolution without breaking backward compatibility. Schema 0x02 (current) adds the recipientIdHash field.
Debug Logging (#8) & Custom Errors (#9)
Typed error hierarchy (ZKProofError, InsufficientBalanceError, EncryptionError, etc.) with recoverable flags, plus per-UTXO decryption diagnostics for development.
Decryption Failure Summary (#10)
Balance methods return diagnostic summaries showing total UTXOs scanned, successfully decrypted, skipped (hash mismatch), and failed — helping verify all expected funds are found.
11. Browser Compatibility
Problem
The official SDK uses Node.js APIs (fs, path, crypto) unavailable in browsers.
Changes
| Node.js API | Browser Replacement |
|---|---|
fs.readFile (circuit files) | fetch() with IndexedDB cache |
crypto.createCipheriv | Web Crypto API (crypto.subtle) |
localStorage | Configurable Storage interface |
| Filesystem paths | URL-based circuitPath parameter |
All cryptographic operations (ZK proof generation, encryption, key derivation) happen entirely in the browser. No private data ever leaves the client.
12. Compact Encryption Headers
Problem
Standard encryption headers use a 9-byte prefix (8-byte version + 1-byte schema) per encrypted output. With two outputs per transaction, this wastes 18 bytes against Solana's 1232-byte transaction limit.
Solution
Replaced the 9-byte header with a single-byte tag: 0xC2 for symmetric (AES-256-GCM) and 0xC3 for asymmetric (NaCl Box). The recipient ID hash follows immediately at byte 1 instead of byte 9.
Standard: [version(8)][schema(1)][recipientIdHash(8)][crypto_payload...] = 17 byte overhead
Compact: [tag(1)][recipientIdHash(8)][crypto_payload...] = 9 byte overhead
Saves 8 bytes per outputTags use high bytes (0xC2/0xC3) to avoid collision with legacy V1 random IV bytes, which have negligible probability of starting with these values.
13. Binary UTXO V2 Encoding
Problem
Binary v1 format includes a 32-byte mintAddress in each encrypted UTXO payload. This is redundant because the receiver already knows the token from the per-token Merkle tree being scanned, and the ZK commitment already binds to the mint on-chain.
Solution
Dropped mintAddress from the binary encoding. The caller provides it from context during decryption.
Binary v1 (0x01, 77 bytes): [flag][amount:8][blinding:32][index:4][mintAddress:32]
Binary v2 (0x02, 45 bytes): [flag][amount:8][blinding:32][index:4]
^^^^^^^^^^^^^^^^
Removed (32 bytes saved)
Savings per transaction: 32 bytes × 2 outputs = 64 bytesThe decoder auto-detects all three formats: binary v2 (0x02 + 45 bytes), binary v1 (0x01 + 77 bytes), and legacy pipe-delimited text. Existing UTXOs remain fully readable.
Security Properties
The fork maintains and extends the security guarantees of the original SDK:
| Property | Mechanism |
|---|---|
| Sender can't spend recipient's UTXO | Pubkey-only mode — no private key available |
| Only recipient decrypts | V3 asymmetric — requires secret key |
| Forward secrecy | Ephemeral keypair per V3 encryption |
| No key leakage | Wallet-adapter mode — private key never exposed |
| RecipientIdHash privacy | Derived from X25519 key (unlinkable to wallet) |
| Authenticated encryption | NaCl Box (Poly1305) prevents tampering |
Further Reading
- Privacy Model — How these modifications enable the full privacy flow
- Cryptography — The encryption primitives used in V3
- UTXO System — How pubkey-only UTXOs work in the shielded pool