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

19 KiB

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


Part 1: Project Setup

Create React App

npm create vite@latest synor-wallet -- --template react-ts
cd synor-wallet
npm install

Install Dependencies

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

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

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

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

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

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

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