synor/docs/tutorials/02-building-a-wallet.md
Gulshan Yadav a6233f285d docs: add developer tutorial series
Create comprehensive step-by-step tutorials:
- Tutorial 1: Getting Started - node setup, wallet creation, transactions
- Tutorial 2: Building a Wallet - React app with state management, encryption
- Tutorial 3: Smart Contracts - Rust WASM contracts, token example, testing
- Tutorial 4: API Guide - JSON-RPC, WebSocket subscriptions, client building

Each tutorial includes working code examples and best practices.
2026-01-10 06:24:51 +05:30

771 lines
19 KiB
Markdown

# 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<Wallet> {
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<Uint8Array> {
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<T> {
jsonrpc: '2.0';
result?: T;
error?: { code: number; message: string };
id: number;
}
let requestId = 0;
export async function rpc<T>(method: string, params: unknown[] = []): Promise<T> {
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<T> = 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<Balance>('synor_getBalance', [address]),
getTransactions: (address: string, limit = 50) =>
rpc<Transaction[]>('synor_getAddressTransactions', [address, limit]),
sendTransaction: (serializedTx: string) =>
rpc<{ txId: string }>('synor_sendRawTransaction', [serializedTx]),
getBlockCount: () =>
rpc<number>('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<string>;
recoverWallet: (mnemonic: string, password: string) => Promise<void>;
unlock: (password: string) => Promise<void>;
lock: () => void;
refreshBalance: () => Promise<void>;
refreshTransactions: () => Promise<void>;
}
// 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<WalletState>()(
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 (
<div className="max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Welcome to Synor Wallet</h1>
<div className="space-y-4">
<button
onClick={() => setStep('create')}
className="w-full p-4 bg-blue-600 text-white rounded-lg"
>
Create New Wallet
</button>
<button
onClick={() => setStep('recover')}
className="w-full p-4 bg-gray-700 text-white rounded-lg"
>
Recover Existing Wallet
</button>
</div>
</div>
);
}
if (step === 'create' && generatedMnemonic) {
return (
<div className="max-w-md mx-auto p-6">
<h2 className="text-xl font-bold mb-4">Save Your Recovery Phrase</h2>
<div className="bg-yellow-900/30 border border-yellow-600 p-4 rounded mb-4">
<p className="text-sm text-yellow-300">
Write down these 24 words in order. This is your only backup!
</p>
</div>
<div className="grid grid-cols-3 gap-2 mb-6">
{generatedMnemonic.split(' ').map((word, i) => (
<div key={i} className="bg-gray-800 p-2 rounded text-sm">
<span className="text-gray-500 mr-2">{i + 1}.</span>
{word}
</div>
))}
</div>
<button
onClick={() => setGeneratedMnemonic('')}
className="w-full p-4 bg-green-600 text-white rounded-lg"
>
I've Saved My Phrase
</button>
</div>
);
}
return (
<div className="max-w-md mx-auto p-6">
<h2 className="text-xl font-bold mb-4">
{step === 'create' ? 'Create Wallet' : 'Recover Wallet'}
</h2>
{error && (
<div className="bg-red-900/30 border border-red-600 p-3 rounded mb-4">
{error}
</div>
)}
{step === 'recover' && (
<div className="mb-4">
<label className="block text-sm mb-2">Recovery Phrase</label>
<textarea
value={mnemonic}
onChange={(e) => setMnemonic(e.target.value)}
className="w-full p-3 bg-gray-800 rounded"
rows={3}
placeholder="Enter your 24 words..."
/>
</div>
)}
<div className="mb-4">
<label className="block text-sm mb-2">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-3 bg-gray-800 rounded"
/>
</div>
<div className="mb-6">
<label className="block text-sm mb-2">Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full p-3 bg-gray-800 rounded"
/>
</div>
<button
onClick={step === 'create' ? handleCreate : handleRecover}
disabled={isLoading}
className="w-full p-4 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{isLoading ? 'Loading...' : step === 'create' ? 'Create' : 'Recover'}
</button>
</div>
);
}
```
### components/Dashboard.tsx
```tsx
import { useEffect } from 'react';
import { useWalletStore } from '../store/wallet';
import { QRCodeSVG } from 'qrcode.react';
export function Dashboard() {
const {
address,
balance,
transactions,
refreshBalance,
refreshTransactions
} = useWalletStore();
useEffect(() => {
refreshBalance();
refreshTransactions();
const interval = setInterval(() => {
refreshBalance();
refreshTransactions();
}, 30000);
return () => clearInterval(interval);
}, []);
return (
<div className="max-w-2xl mx-auto p-6">
{/* Balance Card */}
<div className="bg-gradient-to-r from-blue-600 to-purple-600 p-6 rounded-xl mb-6">
<h2 className="text-sm text-white/70">Total Balance</h2>
<div className="text-4xl font-bold text-white">
{balance?.confirmed || '0.00'} SYNOR
</div>
{balance?.pending && parseFloat(balance.pending) > 0 && (
<div className="text-sm text-white/70 mt-1">
+{balance.pending} pending
</div>
)}
</div>
{/* Receive QR */}
<div className="bg-gray-800 p-6 rounded-xl mb-6">
<h3 className="text-lg font-semibold mb-4">Receive</h3>
<div className="flex items-center gap-6">
<div className="bg-white p-4 rounded-lg">
<QRCodeSVG value={`synor:${address}`} size={120} />
</div>
<div className="flex-1">
<p className="text-sm text-gray-400 mb-2">Your Address</p>
<p className="font-mono text-sm break-all">{address}</p>
<button
onClick={() => navigator.clipboard.writeText(address!)}
className="mt-3 px-4 py-2 bg-gray-700 rounded text-sm"
>
Copy Address
</button>
</div>
</div>
</div>
{/* Recent Transactions */}
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Recent Transactions</h3>
{transactions.length === 0 ? (
<p className="text-gray-500 text-center py-8">No transactions yet</p>
) : (
<div className="space-y-3">
{transactions.slice(0, 5).map((tx) => (
<div
key={tx.txId}
className="flex items-center justify-between p-3 bg-gray-700/50 rounded"
>
<div>
<div className={tx.type === 'receive' ? 'text-green-400' : 'text-red-400'}>
{tx.type === 'receive' ? '+' : '-'}{tx.amount} SYNOR
</div>
<div className="text-xs text-gray-500">
{tx.confirmations} confirmations
</div>
</div>
<div className="text-xs text-gray-500">
{new Date(tx.timestamp * 1000).toLocaleDateString()}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
```
---
## Part 5: Putting It Together
### App.tsx
```tsx
import { useWalletStore } from './store/wallet';
import { CreateWallet } from './components/CreateWallet';
import { Dashboard } from './components/Dashboard';
function App() {
const { hasWallet, isUnlocked } = useWalletStore();
if (!hasWallet) {
return <CreateWallet />;
}
if (!isUnlocked) {
return <UnlockScreen />;
}
return <Dashboard />;
}
function UnlockScreen() {
const { unlock, isLoading, error } = useWalletStore();
const [password, setPassword] = useState('');
return (
<div className="max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Unlock Wallet</h1>
{error && (
<div className="bg-red-900/30 border border-red-600 p-3 rounded mb-4">
{error}
</div>
)}
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-3 bg-gray-800 rounded mb-4"
placeholder="Enter password"
/>
<button
onClick={() => unlock(password)}
disabled={isLoading}
className="w-full p-4 bg-blue-600 text-white rounded-lg"
>
{isLoading ? 'Unlocking...' : 'Unlock'}
</button>
</div>
);
}
export default App;
```
---
## What You've Built
- **Secure wallet creation** with BIP-39 mnemonic
- **Encrypted storage** using AES-256-GCM
- **Balance display** with live updates
- **QR code** for receiving payments
- **Transaction history**
## Next Steps
- Add send functionality (see completed wallet at `apps/web/`)
- Implement transaction signing
- Add network selection
- Deploy to production
---
*Next: [Smart Contracts on Synor](./03-smart-contracts.md)*