Back to Blog
React NativeBlockchainCryptoMobile Developmentethers.js

Building a Self-Custodial Crypto Wallet with React Native

A deep dive into building Pouch — a multi-chain crypto wallet with Uniswap V3 swaps, WalletConnect, and bank-grade encryption using React Native and Expo.

Nawab Khairuzzaman10 min read
Share:
Building a Self-Custodial Crypto Wallet with React Native

Building a crypto wallet from scratch is one of the most challenging mobile projects you can take on. You're dealing with private key management, multi-chain RPC connections, real-time price feeds, and the constant pressure that a single bug could cost someone real money. I built Pouch — a self-custodial wallet supporting 5 EVM chains with built-in Uniswap V3 swaps — and here's everything I learned.

Why Build a Wallet From Scratch?

Existing wallets like MetaMask Mobile and Trust Wallet work fine, but they have problems that are hard to fix from the outside:

  • Bloated UX — too many screens, menus, and options for basic operations
  • Sluggish performance — noticeable lag on mid-range devices
  • No native DeFi — swapping tokens requires navigating to a built-in browser

I wanted a wallet that feels like a polished fintech app, not a crypto tool. Fast transitions, native swap UI, and security that doesn't get in the way.

Architecture Overview

The app follows a layered architecture that separates concerns cleanly:

plaintext
┌─────────────────────────────────────────────┐
│           UI Layer (Expo Router)            │
│  Screens, Components, Animations           │
└─────────────┬───────────────────────────────┘

┌─────────────▼───────────────────────────────┐
│       State Management (React Context)     │
│  Auth, Wallet, Network, WalletConnect      │
│  18+ custom hooks                          │
└─────────────┬───────────────────────────────┘

┌─────────────▼───────────────────────────────┐
│           Services Layer                   │
│  Alchemy · ethers.js · Uniswap V3         │
│  CoinGecko · WalletConnect · Notifications │
└─────────────┬───────────────────────────────┘

┌─────────────▼───────────────────────────────┐
│           Encrypted Storage                │
│  SecureStore (AES-256) · AsyncStorage      │
└─────────────────────────────────────────────┘

I chose React Context + custom hooks over Redux because the wallet's data flow is relatively straightforward — there's no deeply nested state sharing that Redux excels at. Each context (Auth, Wallet, Network) manages its own slice of state, and hooks compose them together.

Key Management: The Most Critical Piece

Everything in a self-custodial wallet comes down to how you handle private keys. Get this wrong and nothing else matters.

Mnemonic Generation

Pouch uses BIP-39 for generating recovery phrases. The mnemonic is created locally on the device and never transmitted over the network:

typescript
import { ethers } from 'ethers';
 
// Generate a cryptographically secure mnemonic
const wallet = ethers.Wallet.createRandom();
const mnemonic = wallet.mnemonic.phrase; // 12-word BIP-39 phrase

Encryption at Rest

The mnemonic and derived private keys are encrypted with AES-256-GCM before being stored using Expo's SecureStore, which leverages the platform's secure enclave (Keychain on iOS, Keystore on Android):

typescript
import * as SecureStore from 'expo-secure-store';
 
// Derive an encryption key from the user's PIN
const encryptionKey = await deriveKey(pin, salt);
 
// Encrypt the mnemonic
const encrypted = await encrypt(mnemonic, encryptionKey);
 
// Store in secure enclave
await SecureStore.setItemAsync('wallet_data', encrypted, {
  keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});

The WHEN_UNLOCKED_THIS_DEVICE_ONLY flag ensures the data can't be extracted from backups or transferred to other devices.

HD Wallet Derivation

For multi-account support, I implemented BIP-44 derivation paths. From a single recovery phrase, users can generate unlimited accounts:

typescript
// BIP-44 path: m/44'/60'/0'/0/index
function deriveAccount(mnemonic: string, index: number) {
  const path = `m/44'/60'/0'/0/${index}`;
  return ethers.HDNodeWallet.fromPhrase(mnemonic, undefined, path);
}
Welcome to Pouch
Recovery Phrase Generation
PIN Setup

The onboarding flow walks users through generating a phrase, verifying it via a randomized quiz, and setting a 6-digit PIN.

Multi-Chain Support

Supporting multiple EVM chains sounds simple — just switch the RPC URL, right? In practice, it's more nuanced.

Network Configuration

Each chain needs its own RPC endpoint, chain ID, block explorer, and native token config:

typescript
interface NetworkConfig {
  chainId: number;
  name: string;
  rpcUrl: string;
  explorerUrl: string;
  nativeToken: {
    symbol: string;
    decimals: number;
    coingeckoId: string;
  };
  uniswapContracts?: {
    router: string;
    quoter: string;
    factory: string;
  };
}

I used Alchemy SDK for reliable RPC access across all 5 networks (Ethereum, Polygon, Arbitrum, Optimism, Base). Running your own nodes is possible but unnecessary for a mobile wallet — Alchemy handles load balancing, failover, and rate limiting.

Token Discovery

Rather than maintaining a static token list, Pouch uses Alchemy's token detection API to automatically discover ERC-20 tokens in the user's wallet:

typescript
import { Alchemy, Network } from 'alchemy-sdk';
 
const alchemy = new Alchemy({
  apiKey: ALCHEMY_KEY,
  network: Network.ETH_MAINNET,
});
 
// Get all token balances for an address
const balances = await alchemy.core.getTokenBalances(address);
 
// Enrich with metadata (name, symbol, decimals, logo)
const tokens = await Promise.all(
  balances.tokenBalances.map(async (token) => {
    const metadata = await alchemy.core.getTokenMetadata(token.contractAddress);
    return { ...token, ...metadata };
  })
);

This approach means users see their tokens immediately without manually importing contract addresses.

Home Dashboard
Full Asset List
Account Switcher

Integrating Uniswap V3

This was the most complex feature. Uniswap V3's concentrated liquidity model requires interacting with several smart contracts and handling quote routing.

Getting a Quote

The Uniswap V3 Quoter contract provides price quotes without executing a swap. I use it to show real-time pricing as the user types:

typescript
import { ethers } from 'ethers';
 
const QUOTER_ABI = [
  'function quoteExactInputSingle((address tokenIn, address tokenOut, uint256 amountIn, uint24 fee, uint160 sqrtPriceLimitX96)) external returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate)',
];
 
async function getQuote(
  tokenIn: string,
  tokenOut: string,
  amountIn: bigint,
  fee: number = 3000 // 0.3% pool
): Promise<bigint> {
  const quoter = new ethers.Contract(QUOTER_ADDRESS, QUOTER_ABI, provider);
 
  const [amountOut] = await quoter.quoteExactInputSingle.staticCall({
    tokenIn,
    tokenOut,
    amountIn,
    fee,
    sqrtPriceLimitX96: 0,
  });
 
  return amountOut;
}

Executing the Swap

The actual swap goes through the SwapRouter contract. The key is setting proper slippage protection:

typescript
async function executeSwap(params: SwapParams) {
  const { tokenIn, tokenOut, amountIn, amountOutMin, fee, deadline } = params;
 
  const router = new ethers.Contract(ROUTER_ADDRESS, ROUTER_ABI, signer);
 
  const tx = await router.exactInputSingle({
    tokenIn,
    tokenOut,
    fee,
    recipient: walletAddress,
    deadline: Math.floor(Date.now() / 1000) + deadline,
    amountIn,
    amountOutMinimum: amountOutMin, // Slippage protection
    sqrtPriceLimitX96: 0,
  });
 
  return tx;
}

The amountOutMinimum is calculated from the quote minus the user's slippage tolerance (default 1%). This prevents sandwich attacks from eating into the swap value.

Swap with Uniswap V3 Quote
Settings - Network, Security, WalletConnect

The swap UI shows the full quote breakdown: exchange rate, price impact, minimum received, slippage tolerance, and the routing path — all fetched from on-chain data.

Send & Receive Flow

Transaction Building

Sending tokens requires careful gas estimation. I fetch the current gas price and let users choose between speed tiers:

typescript
async function buildTransaction(params: SendParams) {
  const { to, amount, token, speed } = params;
 
  const feeData = await provider.getFeeData();
 
  // EIP-1559 gas pricing
  const gasMultiplier = { slow: 0.9, normal: 1, fast: 1.3 };
  const maxFeePerGas = feeData.maxFeePerGas * BigInt(
    Math.round(gasMultiplier[speed] * 100)
  ) / 100n;
 
  if (token.isNative) {
    return {
      to,
      value: ethers.parseEther(amount),
      maxFeePerGas,
      maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
    };
  }
 
  // ERC-20 transfer
  const contract = new ethers.Contract(token.address, ERC20_ABI, signer);
  return contract.transfer.populateTransaction(
    to,
    ethers.parseUnits(amount, token.decimals)
  );
}
Receive QR Code
Send USDT
Transaction Review

The review screen shows the total cost (amount + gas fee) and estimated confirmation time before the user signs.

Real-Time Notifications

Push notifications are critical for a wallet — users need to know immediately when they receive tokens. I used expo-notifications with a backend listener that monitors on-chain events:

typescript
// Backend: Listen for incoming transfers
alchemy.ws.on(
  {
    method: 'alchemy_minedTransactions',
    addresses: [{ to: userAddress }],
  },
  async (tx) => {
    await sendPushNotification(userPushToken, {
      title: `Received ${formatAmount(tx.value)} ${tx.asset}`,
      body: `From ${shortenAddress(tx.from)}`,
    });
  }
);
Push Notification - Received ETH
ETH Price Chart
Transaction History

Performance Optimizations

A crypto wallet needs to feel instant. Here's what made the difference:

Optimistic UI Updates

When a user sends a transaction, I immediately show it in the activity feed as "Pending" without waiting for chain confirmation:

typescript
// Add optimistic entry immediately
addPendingTransaction({
  hash: tx.hash,
  type: 'send',
  amount,
  to,
  status: 'pending',
  timestamp: Date.now(),
});
 
// Confirm in background
tx.wait().then((receipt) => {
  updateTransactionStatus(tx.hash, receipt.status ? 'confirmed' : 'failed');
});

Parallel Data Fetching

On app launch, I fetch all data in parallel rather than sequentially:

typescript
const [balances, prices, transactions, gasPrice] = await Promise.all([
  fetchTokenBalances(address, network),
  fetchPricesFromCoinGecko(tokenIds),
  fetchTransactionHistory(address, network),
  provider.getFeeData(),
]);

This cuts the initial load time from ~3 seconds to under 800ms.

Animation Performance

All animations use React Native Reanimated running on the UI thread. This means smooth 60fps transitions even during heavy JS operations like transaction signing:

typescript
const translateY = useSharedValue(0);
 
const animatedStyle = useAnimatedStyle(() => ({
  transform: [{ translateY: withSpring(translateY.value) }],
}));

Tech Stack Summary

LayerTechnologyWhy
FrameworkReact Native + Expo SDK 54Cross-platform with native module access
LanguageTypeScriptType safety for financial operations
Blockchainethers.js 6TypeScript-first, smaller bundle
RPCAlchemy SDKReliable multi-chain infrastructure
DEXUniswap V3 SDKMost liquid DEX with concentrated liquidity
SecurityAES-256-GCM + SecureStorePlatform secure enclave integration
Authexpo-local-authenticationBiometric (Face ID / Touch ID)
UINativeWind + Reanimated 4Tailwind CSS for RN + 60fps animations
ConnectivityWalletConnect v2dApp connection standard
PricesCoinGecko APIFree tier with real-time data

Lessons Learned

1. Never trust user input for amounts. Always parse and validate token amounts using the token's decimal precision. A rounding error on a send transaction is unrecoverable.

2. Test on real chains. Testnets behave differently from mainnet — gas estimation, token discovery, and swap routing all have subtle differences. I kept a testnet mode toggle in settings for development but always verified on mainnet before shipping.

3. Error messages matter more in crypto. A generic "Transaction failed" is useless. Users need to know if they ran out of gas, hit slippage, or if the token contract reverted. I parse revert reasons from the chain and display human-readable explanations.

4. Secure storage isn't just encryption. It's about the entire lifecycle — generation, encryption, storage, access control, and cleanup. I used WHEN_UNLOCKED_THIS_DEVICE_ONLY to prevent backup extraction and clear sensitive data from memory after use.

5. Price feeds are unreliable. CoinGecko rate limits, APIs go down, prices can be stale. I implemented a multi-source price aggregation with fallbacks and staleness detection.

What's Next

Pouch currently supports 5 EVM chains, but the architecture is designed to be chain-agnostic. Upcoming additions include:

  • Solana support via @solana/web3.js
  • NFT gallery with OpenSea API integration
  • Fiat on-ramp via MoonPay or Transak
  • Hardware wallet support via Ledger Bluetooth SDK

Building a crypto wallet taught me more about mobile security, blockchain internals, and UX design than any other project. If you're thinking about building one, start with the key management layer and get that right before touching anything else.

N

Nawab Khairuzzaman

Full-Stack Web & Blockchain Developer with 6+ years of experience building scalable applications.

Comments

Related Posts