# Tutorial 2: Building a Wallet Application In this tutorial, you'll build a complete wallet application that can create addresses, display balances, and send transactions. ## What You'll Build A React wallet app with: - Mnemonic generation and recovery - Balance display - Transaction history - Send functionality - QR code support ## Prerequisites - Completed [Tutorial 1: Getting Started](./01-getting-started.md) - Node.js 18+ - Basic React knowledge --- ## Part 1: Project Setup ### Create React App ```bash npm create vite@latest synor-wallet -- --template react-ts cd synor-wallet npm install ``` ### Install Dependencies ```bash npm install @synor/sdk zustand @tanstack/react-query qrcode.react ``` ### Project Structure ``` synor-wallet/ ├── src/ │ ├── lib/ │ │ ├── crypto.ts # Crypto utilities │ │ └── rpc.ts # RPC client │ ├── store/ │ │ └── wallet.ts # Wallet state │ ├── components/ │ │ ├── CreateWallet.tsx │ │ ├── Dashboard.tsx │ │ ├── Send.tsx │ │ └── Receive.tsx │ ├── App.tsx │ └── main.tsx ├── package.json └── vite.config.ts ``` --- ## Part 2: Core Wallet Logic ### lib/crypto.ts ```typescript import { generateMnemonic, validateMnemonic, createWallet as sdkCreateWallet } from '@synor/sdk'; export type Network = 'mainnet' | 'testnet'; export interface Wallet { address: string; seed: Uint8Array; keypair: { publicKey: Uint8Array; privateKey: Uint8Array; }; dilithiumPublicKey: Uint8Array; } export function generateNewMnemonic(wordCount: 12 | 24 = 24): string { return generateMnemonic(wordCount); } export function isValidMnemonic(mnemonic: string): boolean { return validateMnemonic(mnemonic); } export async function createWalletFromMnemonic( mnemonic: string, passphrase: string = '', network: Network = 'testnet' ): Promise { return sdkCreateWallet(mnemonic, passphrase, network); } // Encrypt wallet for storage export async function encryptWallet( seed: Uint8Array, password: string ): Promise<{ ciphertext: Uint8Array; iv: Uint8Array; salt: Uint8Array }> { // Use Web Crypto API for AES-256-GCM encryption const encoder = new TextEncoder(); const salt = crypto.getRandomValues(new Uint8Array(16)); // Derive key using PBKDF2 const keyMaterial = await crypto.subtle.importKey( 'raw', encoder.encode(password), 'PBKDF2', false, ['deriveBits', 'deriveKey'] ); const key = await crypto.subtle.deriveKey( { name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' }, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt'] ); const iv = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, seed ); return { ciphertext: new Uint8Array(ciphertext), iv, salt }; } // Decrypt wallet export async function decryptWallet( ciphertext: Uint8Array, iv: Uint8Array, salt: Uint8Array, password: string ): Promise { const encoder = new TextEncoder(); const keyMaterial = await crypto.subtle.importKey( 'raw', encoder.encode(password), 'PBKDF2', false, ['deriveBits', 'deriveKey'] ); const key = await crypto.subtle.deriveKey( { name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' }, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['decrypt'] ); const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv }, key, ciphertext ); return new Uint8Array(decrypted); } ``` ### lib/rpc.ts ```typescript const RPC_URL = 'http://localhost:17110'; interface RpcRequest { jsonrpc: '2.0'; method: string; params: unknown[]; id: number; } interface RpcResponse { jsonrpc: '2.0'; result?: T; error?: { code: number; message: string }; id: number; } let requestId = 0; export async function rpc(method: string, params: unknown[] = []): Promise { const request: RpcRequest = { jsonrpc: '2.0', method, params, id: ++requestId }; const response = await fetch(RPC_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request) }); const data: RpcResponse = await response.json(); if (data.error) { throw new Error(data.error.message); } return data.result as T; } // Typed RPC methods export interface Balance { confirmed: string; pending: string; } export interface Transaction { txId: string; type: 'send' | 'receive'; amount: string; address: string; confirmations: number; timestamp: number; } export const api = { getBalance: (address: string) => rpc('synor_getBalance', [address]), getTransactions: (address: string, limit = 50) => rpc('synor_getAddressTransactions', [address, limit]), sendTransaction: (serializedTx: string) => rpc<{ txId: string }>('synor_sendRawTransaction', [serializedTx]), getBlockCount: () => rpc('synor_getBlockCount', []) }; ``` --- ## Part 3: State Management ### store/wallet.ts ```typescript import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { generateNewMnemonic, createWalletFromMnemonic, encryptWallet, decryptWallet, type Wallet } from '../lib/crypto'; import { api, type Balance, type Transaction } from '../lib/rpc'; interface WalletState { // Persisted hasWallet: boolean; encryptedSeed: { ciphertext: string; iv: string; salt: string } | null; address: string | null; // Session (not persisted) isUnlocked: boolean; wallet: Wallet | null; balance: Balance | null; transactions: Transaction[]; isLoading: boolean; error: string | null; // Actions createWallet: (password: string) => Promise; recoverWallet: (mnemonic: string, password: string) => Promise; unlock: (password: string) => Promise; lock: () => void; refreshBalance: () => Promise; refreshTransactions: () => Promise; } // Convert bytes to hex const toHex = (bytes: Uint8Array) => Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); // Convert hex to bytes const fromHex = (hex: string) => new Uint8Array(hex.match(/.{1,2}/g)!.map(b => parseInt(b, 16))); export const useWalletStore = create()( persist( (set, get) => ({ // Initial state hasWallet: false, encryptedSeed: null, address: null, isUnlocked: false, wallet: null, balance: null, transactions: [], isLoading: false, error: null, createWallet: async (password) => { set({ isLoading: true, error: null }); try { const mnemonic = generateNewMnemonic(24); const wallet = await createWalletFromMnemonic(mnemonic, '', 'testnet'); const encrypted = await encryptWallet(wallet.seed, password); set({ hasWallet: true, encryptedSeed: { ciphertext: toHex(encrypted.ciphertext), iv: toHex(encrypted.iv), salt: toHex(encrypted.salt) }, address: wallet.address, isUnlocked: true, wallet, isLoading: false }); return mnemonic; // Return for user to save } catch (error) { set({ error: (error as Error).message, isLoading: false }); throw error; } }, recoverWallet: async (mnemonic, password) => { set({ isLoading: true, error: null }); try { const wallet = await createWalletFromMnemonic(mnemonic, '', 'testnet'); const encrypted = await encryptWallet(wallet.seed, password); set({ hasWallet: true, encryptedSeed: { ciphertext: toHex(encrypted.ciphertext), iv: toHex(encrypted.iv), salt: toHex(encrypted.salt) }, address: wallet.address, isUnlocked: true, wallet, isLoading: false }); } catch (error) { set({ error: (error as Error).message, isLoading: false }); throw error; } }, unlock: async (password) => { const { encryptedSeed, address } = get(); if (!encryptedSeed || !address) { throw new Error('No wallet found'); } set({ isLoading: true, error: null }); try { const seed = await decryptWallet( fromHex(encryptedSeed.ciphertext), fromHex(encryptedSeed.iv), fromHex(encryptedSeed.salt), password ); const wallet = await createWalletFromMnemonic( '', // We derive from seed directly '', 'testnet' ); set({ isUnlocked: true, wallet, isLoading: false }); // Refresh balance get().refreshBalance(); } catch (error) { set({ error: 'Incorrect password', isLoading: false }); throw error; } }, lock: () => { set({ isUnlocked: false, wallet: null, balance: null, transactions: [] }); }, refreshBalance: async () => { const { address, isUnlocked } = get(); if (!address || !isUnlocked) return; try { const balance = await api.getBalance(address); set({ balance }); } catch (error) { console.error('Failed to refresh balance:', error); } }, refreshTransactions: async () => { const { address, isUnlocked } = get(); if (!address || !isUnlocked) return; try { const transactions = await api.getTransactions(address); set({ transactions }); } catch (error) { console.error('Failed to refresh transactions:', error); } } }), { name: 'synor-wallet', partialize: (state) => ({ hasWallet: state.hasWallet, encryptedSeed: state.encryptedSeed, address: state.address }) } ) ); ``` --- ## Part 4: Components ### components/CreateWallet.tsx ```tsx import { useState } from 'react'; import { useWalletStore } from '../store/wallet'; export function CreateWallet() { const { createWallet, recoverWallet, isLoading } = useWalletStore(); const [step, setStep] = useState<'choice' | 'create' | 'recover'>('choice'); const [mnemonic, setMnemonic] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [generatedMnemonic, setGeneratedMnemonic] = useState(''); const [error, setError] = useState(''); const handleCreate = async () => { if (password !== confirmPassword) { setError('Passwords do not match'); return; } if (password.length < 8) { setError('Password must be at least 8 characters'); return; } try { const newMnemonic = await createWallet(password); setGeneratedMnemonic(newMnemonic); setStep('create'); } catch (err) { setError((err as Error).message); } }; const handleRecover = async () => { if (password !== confirmPassword) { setError('Passwords do not match'); return; } try { await recoverWallet(mnemonic, password); } catch (err) { setError((err as Error).message); } }; if (step === 'choice') { return (

Welcome to Synor Wallet

); } if (step === 'create' && generatedMnemonic) { return (

Save Your Recovery Phrase

Write down these 24 words in order. This is your only backup!

{generatedMnemonic.split(' ').map((word, i) => (
{i + 1}. {word}
))}
); } return (

{step === 'create' ? 'Create Wallet' : 'Recover Wallet'}

{error && (
{error}
)} {step === 'recover' && (