Chromia
Start Building

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:

client/package.json
{  "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:

client/src/services/secureNoteManager.js
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:

client/src/services/ft4Client.js
// 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:

client/src/utils/crypto.js
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:

client/src/pages/index.js
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:

client/src/services/secureNoteManager.js
// 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:

  1. Password-Protected Wallet: the master seed comes from combining an EVM signature with a password
  2. Automatic Lock: after 30 minutes without activity, the wallet locks itself
  3. Asymmetric Encryption: notes are encrypted using derived keypairs
  4. Session Management: sessions are handled securely through MetaMask integration
  5. 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.

Written for this site from Chromia’s public course. Code samples are the course’s own.