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.
771 lines
19 KiB
Markdown
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)*
|