18 / 19Module 3 – FrontendAdvanced
Frontend architecture
This frontend is a Next.js application that gives users a full interface for running zero-knowledge proof (ZKP) operations on Chromia. The sections below walk through how it is built.
Key dependencies
ZKP operations in the frontend depend on the following libraries:
{ "dependencies": { "@chromia/ft4": "^2.0.0", // FT4 token standard "circomlibjs": "^0.1.7", // Circom library for ZK circuits "crypto-js": "^4.1.1", // Cryptographic operations "ethers": "^5.7.2", // Ethereum wallet integration "postchain-client": "^2.0.0", // Chromia blockchain client "snarkjs": "^0.7.0" // zk-SNARK proof generation }}Architecture overview
The code splits into these main parts:
- SecureNoteManager: the core ZKP logic that manages private notes
- ZKP utilities: create and verify proofs
- Crypto utilities: hashing and other cryptographic operations
- FT4Client: the layer that talks to the blockchain
- React components: the UI through which users run ZKP operations
SecureNoteManager - core ZKP engine
At the center of the ZKP system sits the SecureNoteManager class. It holds the cryptographic keys and manages the encrypted notes:
class SecureNoteManager { constructor() { this.poseidon = null; // Poseidon hash function this.masterSeed = null; // Master seed for key derivation this.spendingKey = null; // ZK spending key this.viewingKey = null; // ZK viewing key this.encryptionPrivateKey = null; // Asymmetric encryption key this.encryptionPublicKey = null; // Asymmetric public key this.notes = new Map(); // commitment -> note data this.nullifiers = new Set(); // Used nullifiers this.isUnlocked = false; // Security state } // Generate secure deterministic master seed from EVM signature + password async generateSecureMasterSeed(password, accountId, evmWallet) { const message = `ZKP_WALLET_MASTER_SEED_${password}_${accountId}`; // Get signature from EVM wallet (MetaMask) const signature = await evmWallet.signMessage(message); // Derive deterministic master seed from signature const signatureSeed = ethers.utils.keccak256(signature); const masterSeed = ethers.utils.keccak256( ethers.utils.concat([ ethers.utils.arrayify(signatureSeed), ethers.utils.toUtf8Bytes(password), ethers.utils.toUtf8Bytes(accountId) ]) ); return ethers.utils.arrayify(masterSeed); } // Derive ZK keys for zero-knowledge proofs async deriveZKKeys() { const masterKey = ethers.utils.keccak256(this.masterSeed); // Derive spending key (for generating nullifiers and proving ownership) this.spendingKey = this.poseidon.F.toString( this.poseidon([BigInt(masterKey)]) ); // Derive viewing key (for ZK proofs) this.viewingKey = this.poseidon.F.toString( this.poseidon([BigInt(this.spendingKey), BigInt(1)]) ); }}ZKP utilities - proof generation
When you make a private transfer, these utilities produce the required zero-knowledge proofs:
// Generate a proof for a private transferexport const generateProof = async ({ privateSpendKey, amount_input, privateAddress_input, blindingFactor_input, transfer_amount, blindingFactor_output_sender, blindingFactor_output_recipient, recipient_privateAddress}) => { // Initialize crypto if needed await initCrypto(); // Calculate input commitment (for verification) const commitment_input = hashValues([ amount_input, privateAddress_input, blindingFactor_input ]); // Calculate sender's change amount const sender_change = BigInt(amount_input) - BigInt(transfer_amount); // Calculate output commitments const commitment_output_sender = hashValues([ sender_change, privateAddress_input, blindingFactor_output_sender ]); const commitment_output_recipient = hashValues([ transfer_amount, recipient_privateAddress, blindingFactor_output_recipient ]); // Calculate nullifier (prevents double-spending) const nullifier = hashValues([privateSpendKey, commitment_input]); // Prepare inputs for the circuit const inputs = { // Private inputs (hidden from verifier) privateSpendKey: privateSpendKey.toString(), amount_input: amount_input.toString(), privateAddress_input: privateAddress_input.toString(), blindingFactor_input: blindingFactor_input.toString(), transfer_amount: transfer_amount.toString(), blindingFactor_output_sender: blindingFactor_output_sender.toString(), blindingFactor_output_recipient: blindingFactor_output_recipient.toString(), // Public inputs (visible to verifier) commitment_input: commitment_input.toString(), commitment_output_sender: commitment_output_sender.toString(), commitment_output_recipient: commitment_output_recipient.toString(), recipient_privateAddress: recipient_privateAddress.toString(), nullifier: nullifier.toString() }; try { // Generate the ZK proof using snarkjs const { proof, publicSignals } = await snarkjs.plonk.fullProve( inputs, '/private_transfer.wasm', // Circuit WASM file '/circuit.zkey' // Proving key ); // Format the proof for Postchain const proofData = Object.values(proof).flat().map(value => value.toString()); return { proof: proofData, publicSignals: publicSignals.map(signal => signal.toString()), commitment_input, commitment_output_sender, commitment_output_recipient, recipient_privateAddress, nullifier, sender_change }; } catch (error) { console.error('Error generating proof:', error); throw error; }};Crypto utilities - cryptographic operations
These utilities supply the basic cryptographic functions the rest of the app depends on:
import CryptoJS from 'crypto-js';// Initialize circomlib for Poseidon hashinglet poseidon;export const initCrypto = async () => { if (!poseidon) { const circomlibjs = await import('circomlibjs'); poseidon = await circomlibjs.buildPoseidon(); } return { poseidon };};// Generate a random blinding factor for commitmentsexport const generateBlindingFactor = () => { const buf = new Uint8Array(32); window.crypto.getRandomValues(buf); return BigInt('0x' + Array.from(buf).map(b => b.toString(16).padStart(2, '0')).join(''));};// Derive private address from private spend keyexport const derivePrivateAddress = async (privateSpendKey) => { await initCrypto(); return hashValues([privateSpendKey]);};// Hash values using Poseidon (simplified version)export function hashValues(values) { const combined = values.map(v => v.toString()).join(','); return BigInt('0x' + CryptoJS.SHA256(combined).toString().slice(0, 16));}// Encrypt note for recipientexport function encryptNote(note, privateAddress) { // Convert private address to encryption key const keyHex = BigInt(privateAddress).toString(16).padStart(64, '0'); const key = keyHex.slice(0, 64); // Encrypt using AES const noteJson = JSON.stringify(note); const encrypted = CryptoJS.AES.encrypt(noteJson, key).toString(); return Buffer.from(encrypted, 'utf8');}// Decrypt note if intended for this addressexport function decryptNote(encryptedNote, privateAddress) { try { const keyHex = BigInt(privateAddress).toString(16).padStart(64, '0'); const key = keyHex.slice(0, 64); const encryptedString = Buffer.isBuffer(encryptedNote) ? encryptedNote.toString('utf8') : encryptedNote; const decrypted = CryptoJS.AES.decrypt(encryptedString, key); const decryptedString = decrypted.toString(CryptoJS.enc.Utf8); return decryptedString ? JSON.parse(decryptedString) : null; } catch (error) { return null; // Note not intended for this address }}Frontend user interface
The UI for ZKP operations lives in the main React component:
export default function Home() { // State management for ZKP operations const [ft4Client, setFt4Client] = useState(null); const [isWalletUnlocked, setIsWalletUnlocked] = useState(false); const [publicBalance, setPublicBalance] = useState('0'); const [privateBalance, setPrivateBalance] = useState('0'); const [showPasswordModal, setShowPasswordModal] = useState(false); // Initialize FT4 Client with ZKP support useEffect(() => { const initializeClient = async () => { const client = new FT4Client(); await client.initialize(); setFt4Client(client); // Check for existing sessions const existingSession = await client.checkExistingSession(); if (existingSession) { // Restore session and sync private notes if (client.isWalletUnlocked()) { await client.syncPrivateNotes(); } } }; initializeClient(); }, []); // Handle shielding tokens (public -> private) const handleShield = async () => { const performShield = async () => { const loadingToast = toast.loading('Shielding tokens... Generating ZK proof...'); try { // Convert to blockchain format const shieldAmountFormatted = formatAmount(shieldAmount); // Generate ZK proof and shield tokens await ft4Client.shieldTokens(BigInt(shieldAmountFormatted)); // Refresh balances await refreshBalances(); toast.success(`Successfully shielded ${shieldAmount} tokens!`, { id: loadingToast }); } catch (error) { toast.error('Failed to shield tokens', { id: loadingToast }); } }; // Prompt for password if wallet is locked if (!isWalletUnlocked) { promptForPassword('shield', performShield); } else { await performShield(); } }; // Handle private transfers const handleTransfer = async () => { const performTransfer = async () => { const loadingToast = toast.loading('Processing private transfer... Generating ZK proof...'); try { const transferAmountFormatted = formatAmount(transferAmount); // Generate ZK proof and perform private transfer await ft4Client.privateTransfer( BigInt(transferAmountFormatted), transferRecipient ); await refreshBalances(); toast.success(`Successfully transferred ${transferAmount} tokens privately!`, { id: loadingToast }); } catch (error) { toast.error('Failed to complete private transfer', { id: loadingToast }); } }; if (!isWalletUnlocked) { promptForPassword('transfer', performTransfer); } else { await performTransfer(); } }; // Handle unshielding tokens (private -> public) const handleUnshield = async () => { const performUnshield = async () => { const loadingToast = toast.loading('Unshielding tokens... Generating ZK proof...'); try { const unshieldAmountFormatted = formatAmount(unshieldAmount); // Generate ZK proof and unshield tokens await ft4Client.unshieldTokens(BigInt(unshieldAmountFormatted)); await refreshBalances(); toast.success(`Successfully unshielded ${unshieldAmount} tokens!`, { id: loadingToast }); } catch (error) { toast.error('Failed to unshield tokens', { id: loadingToast }); } }; if (!isWalletUnlocked) { promptForPassword('unshield', performUnshield); } else { await performUnshield(); } };}Note management and privacy
Private notes are stored and handled under strong encryption:
// Create a new private noteasync createNote(amount, recipientPrivateAddress = null) { this.requireUnlocked(); const privateAddress = recipientPrivateAddress || this.getPrivateAddress(); const blindingFactor = this.generateBlindingFactor(); // Create commitment: hash(amount, privateAddress, blindingFactor) const commitment = this.createCommitment(amount, privateAddress, blindingFactor); const noteData = { amount: amount.toString(), privateAddress: privateAddress.toString(), blindingFactor, commitment, isOwned: privateAddress.toString() === this.getPrivateAddress().toString(), createdAt: Date.now() }; // Store note locally if we own it if (noteData.isOwned) { this.notes.set(commitment, noteData); await this.saveNotesToStorage(); } return noteData;}// Save notes with asymmetric encryptionasync saveNotesToStorage() { const notesData = { notes: Array.from(this.notes.entries()), nullifiers: Array.from(this.nullifiers), version: '2.0', keypairId: this.keypairSeed.slice(0, 8), publicEncryptionKey: this.encryptionPublicKey }; const publicKeyBase64 = this.getPublicEncryptionKey(); const encrypted = await this.encryptDataAsymmetric( this.safeStringify(notesData), publicKeyBase64 ); const encryptedBase64 = btoa(String.fromCharCode(...encrypted)); localStorage.setItem(this.getStorageKey('encrypted_notes'), encryptedBase64);}ZKP operation flow
The frontend covers every step of the ZKP workflow:
1. Shield operation (public → private)
- You enter the amount you want to shield
- The frontend creates a ZK proof showing you own the public tokens
- The proof is sent to the blockchain
- Your public balance goes down and a new private note is created
2. Private transfer
- You choose a recipient and an amount
- The frontend selects private notes that can cover the transfer
- A ZK proof is created that demonstrates:
- You own the input notes
- The amounts add up correctly
- The nullifiers are valid, which blocks double-spending
- New private notes are created: one for the recipient and one for your change
3. Unshield operation (private → public)
- You enter the amount you want to unshield
- The frontend creates a ZK proof showing you own the private notes
- Those private notes are nullified
- Your public balance goes up
Security features
Several protections are built into the frontend:
- Password-Protected Wallet: the master seed comes from combining an EVM signature with a password
- Automatic Lock: after 30 minutes without activity, the wallet locks itself
- Asymmetric Encryption: notes are encrypted using derived keypairs
- Session Management: sessions are handled securely through MetaMask integration
- Note Validation: nullifier tracking stops notes from being spent twice
Taken together, this design gives you a secure and usable interface for zero-knowledge proof operations on Chromia, and it shows that standard web tooling can put complex cryptography within reach of ordinary users.