January 15, 2025

Build a Basic Crypto Wallet in Solidity

12 min readBlockchain â€ĸ Advanced
Cappy Wallet welcome screen - client-side cryptocurrency wallet built with JavaScript

This assignment will teach you the low-level mechanics of how transactions work on Ethereum by implementing core wallet functionality manually.

đŸŽ¯ Assignment: Build a Basic Crypto Wallet in Solidity

Your task is to build a basic cryptocurrency wallet from scratch with a key constraint: you cannot use existing wallet or transaction libraries. You must implement the core functionality manually.

🔍 Important Clarification: Two Types of Wallets

While the assignment title mentions "Solidity," the actual requirements (nonce management, gas estimation, raw transaction creation) describe building a client-side wallet application, not a Solidity smart contract.

đŸ—ī¸ Solidity Smart Contract Wallet

A contract deployed on-chain that holds funds with programmable logic (multi-sig, time locks, etc.)

đŸ’ģ Client-Side Wallet Application

A JavaScript app (like MetaMask) that manages private keys and creates transactions manually

CapyWallet (shown below) demonstrates the client-side approach - building a JavaScript application that manually handles transaction creation, signing, and broadcasting without wallet libraries.

Key Requirements

This assignment focuses on the fundamental aspects of transaction management that are usually abstracted away by libraries like ethers.js or web3.js.

1. Account Nonce Management

Retrieve and manually manage the nonce (number used once) of accounts. The nonce prevents replay attacks and ensures transaction ordering.

  • Track current nonce for each account
  • Increment nonce with each transaction
  • Handle nonce synchronization with network state

2. Gas Estimation

Manually estimate the gas required for transactions without using library abstractions.

  • Calculate gas limits for different transaction types
  • Estimate gas prices based on network conditions
  • Handle gas fee calculations (base fee + priority fee)

3. Raw Transaction Creation

Construct raw transactions, handle cryptographic signing, and broadcast to the blockchain.

  • Build transaction objects with all required fields
  • Implement transaction signing using private keys
  • Serialize transactions for network transmission
  • Handle transaction broadcasting and confirmation

Allowed Tools & Restrictions

✅ Allowed

  • Cryptographic libraries (for hashing, signing transactions, etc.)
  • JSON-RPC clients for basic network communication
  • Utilities for encoding/decoding data formats
  • Testing frameworks and development tools

❌ Not Allowed

  • Existing wallet libraries (ethers.js, web3.js, etc.)
  • Libraries that abstract transaction management
  • Pre-built wallet SDKs or frameworks
  • Any tool that handles nonce management automatically

What You're Actually Building

Despite the "Solidity" title, the assignment requirements point to building client-side wallet functionality using JavaScript/React - similar to how MetaMask works. You're implementing the low-level transaction handling that libraries like ethers.js and web3.js normally provide.

đŸ—ī¸ Manual Implementation Key Points

Here's what you'll manually implement:

1

Private Key Derivation: Your private key is used to manually derive your wallet address using cryptographic functions

2

Manual Transaction Creation: All transactions are manually created and signed without wallet libraries like ethers.js

3

Manual Nonce & Gas Management: Nonce tracking and gas estimation are implemented from scratch

4

Direct Blockchain Communication: Raw JSON-RPC calls to interact with Ethereum nodes

Wallet Interface Walkthrough

Here is CapyWallet - MY Wallet interface.

Note: You do NOT need any of the extra styling or graphics, or a capybara đŸĻĢ. I just like to be a little extra. 😉

This walkthrough shows the entire user flow from welcome to sending transactions.

🚨 Important: No Remix Required

This is a client-side JavaScript/React application that runs in your regular development environment. You'll see `localhost:3000` in your browser, not the Remix IDE. The wallet communicates with the blockchain via JSON-RPC calls, just like MetaMask does.

1. Connect Wallet Options

My wallet provides multiple connection options for users to access their cryptocurrency accounts. Here's how the manual wallet connection works:

🔧 Manual Connection Process

  • Private Key Input: Users enter their private key directly (testnet only for safety)
  • Address Derivation: Wallet address is manually derived from the private key using cryptographic functions
  • No MetaMask Required: Direct blockchain interaction without external wallet dependencies
  • Session Storage: Private key temporarily stored for transaction signing
Cappy Wallet connection options interface

Connection interface showing different wallet access methods

2. Test Wallet Generation

I added a test-wallet generator (green button) for development and testing purposes. This provides a safe environment and allows you to test it out without needing to connect your actual wallet or key.

🔐 Test Wallet Generation Code

// Test wallet generation function - Creates a random wallet for testing
const handleGenerateWallet = () => {
  try {
    // Clear any previous error messages from the UI state
    setError(""); 
    
    // Call our custom function to generate a new wallet
    // This creates a random private key and derives the address
    const wallet = generateTestWallet();
    
    // Store the generated private key in component state
    // This will be used later for transaction signing
    setPrivateKey(wallet.privateKey);
    
    // Log the address for debugging (never log private keys!)
    console.log("Generated wallet:", wallet.address);
  } catch (err) {
    // Handle any errors during wallet generation
    // Display user-friendly error message in the UI
    setError("Failed to generate wallet: " + err.message);
  }
};
  • Random Generation: Creates cryptographically secure random private keys
  • Instant Address: Automatically derives the corresponding wallet address
  • Testnet Safe: Only for use on Sepolia and other testnets
  • Educational Only: Perfect for learning without financial risk
Cappy Wallet test environment selection

Test wallet option for safe development and testing

3. Private Key Entry

Secure private key management - the core of wallet functionality:

Cappy Wallet private key entry interface

Private key entry interface with security considerations

4. Send ETH Interface

The core transaction functionality - manual nonce management, gas estimation, and transaction signing. This is where all the manual implementation comes together:

🔧 Manual Transaction Implementation

// Manual balance loading with multiple RPC fallbacks for reliability
const loadWalletBalance = async (address: string) => {
  // Array of backup RPC endpoints - if one fails, try the next
  // This prevents single point of failure in network communication
  const rpcUrls = [
    'https://ethereum-sepolia-rpc.publicnode.com', // Primary endpoint
    'https://sepolia.gateway.tenderly.co',         // Backup #1
    'https://rpc.sepolia.org'                      // Backup #2
  ];
  
  // Loop through each RPC endpoint until one succeeds
  for (const rpcUrl of rpcUrls) {
    try {
      // Create a timeout promise to prevent hanging requests
      // If RPC doesn't respond in 5 seconds, move to next endpoint
      const timeoutPromise = new Promise((_, reject) => {
        setTimeout(() => reject(new Error('Timeout')), 5000);
      });
      
      // Make the actual balance request to the blockchain
      const balancePromise = getBalanceManually(address, rpcUrl);
      
      // Race the balance request against the timeout
      // Whichever resolves first wins (balance or timeout error)
      balance = await Promise.race([balancePromise, timeoutPromise]);
      
      // If we get here, the request succeeded - exit the loop
      break;
    } catch (error) {
      // This RPC failed, continue to the next one
      // Don't throw error yet - we have more endpoints to try
      continue;
    }
  }
};

// Manual transaction sending - completely bypasses wallet libraries
// This is what ethers.js or web3.js would normally handle for you
const tx = await sendRawTransaction(
  privateKey,    // Your secret key for signing
  fromAddress,   // Source wallet address
  toAddress,     // Destination address
  amount         // Amount of ETH to send
);
  • RPC Redundancy: Multiple endpoints with timeout handling for reliability
  • Manual Balance Fetching: Direct blockchain queries without wallet libraries
  • Raw Transaction Creation: Manual transaction construction and signing
  • Private Key Management: Direct private key handling for transaction signing

✅ Manual Validation & Gas Estimation

// Manual address validation - check if recipient address is valid Ethereum format
// This prevents sending ETH to invalid addresses (funds would be lost)
if (!isValidAddress(toAddress)) {
  // Return error response instead of proceeding with invalid address
  return json({ success: false, error: 'Invalid recipient address' });
}

// Manual balance checking - calculate total transaction cost
// Total cost = amount to send + gas fees (both in ETH)
const totalCost = (parseFloat(amount || '0') + parseFloat(gasPrice)).toFixed(6);

// Check if user has enough ETH to cover the transaction
// This prevents "insufficient funds" errors from the blockchain
const isInsufficientBalance = parseFloat(amount) > parseFloat(currentBalance);

// Manual gas price calculation - estimate network fees
// In production, you'd fetch current gas prices from the network
// Here we use a fixed estimate for Sepolia testnet (0.000021 ETH)
const [gasPrice] = useState('0.000021');

// Manual form validation - check all conditions before enabling send button
// This prevents users from submitting incomplete or invalid transactions
disabled={
  !amount ||                                                // Amount must be entered
  !recipientAddress ||                                      // Recipient required
  !isValidRecipient ||                                      // Address must be valid format
  parseFloat(amount) <= 0 ||                               // Amount must be positive
  parseFloat(amount) > parseFloat(currentBalance) ||       // Can't send more than balance
  !showPrivateKeyWarning                                   // User must acknowledge risks
}
  • Address Validation: Manual Ethereum address format checking
  • Balance Validation: Manual insufficient funds checking
  • Gas Calculation: Manual gas price estimation and total cost calculation
  • Form State Management: Manual validation logic without form libraries
Cappy Wallet send ETH transaction interface

Transaction interface showing manual implementation of wallet core functionality

5. Receive ETH Interface

Address management and receiving functionality with manual QR code generation and clipboard integration:

📱 Manual QR Code & Address Management

// Manual QR Code generation (no libraries) - for easy address sharing
const generateQRCode = (address: string) => {
  // Using external API service instead of importing QR code libraries
  // This keeps our bundle size small and avoids library dependencies
  const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${address}`;
  
  // Store the QR code URL in component state for display
  setQrCodeDataUrl(qrUrl);
};

// Manual clipboard functionality - copy address to user's clipboard
const copyToClipboard = async (text: string) => {
  try {
    // Use modern Clipboard API (available in all modern browsers)
    // This is more secure than deprecated document.execCommand('copy')
    await navigator.clipboard.writeText(text);
    
    // Show visual feedback that copy was successful
    setCopiedAddress(true);
    
    // Hide the "copied" message after 2 seconds
    setTimeout(() => setCopiedAddress(false), 2000);
  } catch (err) {
    // Handle clipboard errors (permissions, old browsers, etc.)
    console.error('Failed to copy:', err);
  }
};

// Address formatting for display - make long addresses readable
// Example: 0x1234...5678 instead of showing full 42-character address
const formatAddress = (address: string) => {
  // Take first 6 characters (0x1234) + "..." + last 4 characters (5678)
  return `${address.slice(0, 6)}...${address.slice(-4)}`;
};
  • Manual QR Generation: Uses external API instead of QR code libraries
  • Clipboard API: Direct browser clipboard integration without libraries
  • Address Formatting: Manual string manipulation for user-friendly display
  • Wallet Connection Loading: Retrieves wallet data from client-side storage
Cappy Wallet receive ETH interface

Receive interface for managing incoming transactions

Technical Implementation Details

Now that you understand what you're building, let's dive into the technical details of manual wallet implementation:

Raw Transaction Structure

Here's the raw transaction structure you'll need to construct manually in your JavaScript wallet implementation:

// JavaScript transaction object structure
const transaction = {
  nonce: 42,                    // Account transaction counter
  gasPrice: '20000000000',      // 20 Gwei in wei
  gasLimit: 21000,              // Gas limit for transaction
  to: '0x742E...C9aB',          // Recipient address
  value: '1000000000000000000', // 1 ETH in wei
  data: '0x',                   // Transaction data (empty for ETH transfer)
  chainId: 1,                   // Ethereum mainnet (use testnet for development)
  // Signature components (added after signing)
  v: undefined,
  r: undefined,
  s: undefined
}

Key Transaction Fields:

  • nonce: Account transaction counter (prevents replay attacks)
  • gasPrice & gasLimit: Gas fee parameters you must calculate
  • to & value: Recipient address and ETH amount
  • v, r, s: Cryptographic signature components you must generate

Real Code Examples from CapyWallet

Here are actual code examples from the CapyWallet implementation showing how each manual component works in practice:

🔄 Manual Form State Management

// Manual form state without form libraries
const [amount, setAmount] = useState('');
const [recipientAddress, setRecipientAddress] = useState('');
const [isValidRecipient, setIsValidRecipient] = useState(true);
const [showPrivateKeyWarning, setShowPrivateKeyWarning] = useState(false);

// Manual input validation
const handleRecipientChange = (value) => {
  setRecipientAddress(value);
  setIsValidRecipient(value === '' || isValidAddress(value));
};

// Manual transaction cost calculation
const usdValue = (parseFloat(amount) * 2400).toFixed(2);
const totalCost = (parseFloat(amount || '0') + parseFloat(gasPrice)).toFixed(6);

// Manual form submission validation
const isFormValid = amount && 
  recipientAddress && 
  isValidRecipient &&
  parseFloat(amount) > 0 && 
  parseFloat(amount) <= parseFloat(currentBalance) &&
  showPrivateKeyWarning;
  • Manual State Management: React state without external form libraries
  • Real-time Validation: Manual input validation on every change
  • Cost Calculation: Manual USD conversion and gas fee calculation
  • Form Logic: Manual submission validation and error handling

Manual RPC Communication (JavaScript)

Instead of using wallet libraries, you communicate directly with Ethereum nodes using JSON-RPC calls. This gives you full control over network requests and allows fallback to multiple endpoints for reliability.

// Manual RPC communication with fallback endpoints for maximum reliability
// This array provides backup options if primary RPC endpoint fails
const rpcUrls = [
  'https://ethereum-sepolia-rpc.publicnode.com',  // Free public RPC
  'https://sepolia.gateway.tenderly.co',          // Tenderly's gateway
  'https://rpc.sepolia.org'                       // Community RPC
];

// Manual balance fetching function - bypasses all wallet libraries
async function getBalanceManually(address, rpcUrl) {
  // Make HTTP POST request to RPC endpoint
  // This is exactly what MetaMask does behind the scenes
  const response = await fetch(rpcUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      jsonrpc: '2.0',              // JSON-RPC protocol version
      method: 'eth_getBalance',    // Ethereum method to get balance
      params: [address, 'latest'], // Address and block number
      id: 1                        // Request ID for tracking
    })
  });
  
  // Parse JSON response from the blockchain node
  const data = await response.json();
  
  // Convert balance from wei (smallest unit) to ETH for display
  // 1 ETH = 10^18 wei
  return formatEther(data.result);
}

// Retry logic with timeout handling - ensures reliability
// Try each RPC endpoint until one succeeds
for (const rpcUrl of rpcUrls) {
  try {
    // Create timeout promise - don't wait forever for slow endpoints
    const timeoutPromise = new Promise((_, reject) => {
      setTimeout(() => reject(new Error('Timeout')), 5000);
    });
    
    // Make the balance request to current RPC endpoint
    const balancePromise = getBalanceManually(address, rpcUrl);
    
    // Race the request against timeout - whichever finishes first wins
    balance = await Promise.race([balancePromise, timeoutPromise]);
    
    // Success! Exit the retry loop
    break;
  } catch (error) {
    // This endpoint failed, try the next one in the array
    continue;
  }
}

Manual Transaction Validation (JavaScript)

Before sending any transaction, you must validate all inputs manually. This includes checking address formats, verifying balances, and ensuring private keys are present - all without relying on library validation functions.

// Manual address validation without libraries - critical security function
function isValidAddress(address) {
  // Ethereum addresses must be exactly 42 characters:
  // "0x" prefix + 40 hexadecimal characters (0-9, a-f, A-F)
  // This prevents sending funds to invalid/malformed addresses
  const addressRegex = /^0x[a-fA-F0-9]{40}$/;
  return addressRegex.test(address);
}

// Manual transaction validation - check everything before sending
// This prevents expensive failed transactions on the blockchain
async function validateTransaction(fromAddress, toAddress, amount, privateKey) {
  
  // 1. Recipient address validation
  // Invalid addresses would cause permanent loss of funds
  if (!isValidAddress(toAddress)) {
    throw new Error('Invalid recipient address');
  }
  
  // 2. Sender address validation
  // Ensures the "from" address is properly formatted
  if (!isValidAddress(fromAddress)) {
    throw new Error('Invalid sender address');
  }
  
  // 3. Private key validation
  // Without private key, we can't sign the transaction
  if (!privateKey) {
    throw new Error('Private key required for transaction signing');
  }
  
  // 4. Balance validation - prevent insufficient funds errors
  // Check if sender has enough ETH to cover amount + gas fees
  const balance = await getBalanceManually(fromAddress);
  
  // Manual gas estimation (in production, fetch from network)
  // Gas price = 21000 gwei * current gas price per gwei
  const gasPrice = 0.000021;
  
  // Calculate total cost: amount to send + network fees
  const totalCost = parseFloat(amount) + gasPrice;
  
  // Ensure user has enough funds for transaction + fees
  if (totalCost > parseFloat(balance)) {
    throw new Error('Insufficient balance for transaction');
  }
  
  // All validations passed - safe to proceed with transaction
  return true;
}

Manual Transaction Creation & Signing (JavaScript)

The core of the assignment: manually building transaction objects, fetching nonces, calculating gas, signing with private keys, and broadcasting to the network. This replaces what ethers.js or web3.js would normally handle automatically.

// Manual raw transaction creation - the heart of the wallet
// This function replaces what ethers.js or web3.js would do automatically
async function sendRawTransaction(privateKey, fromAddress, toAddress, amount) {
  
  // 1. Get current nonce manually from the blockchain
  // Nonce prevents replay attacks and ensures transaction ordering
  const nonce = await getNonceManually(fromAddress);
  
  // 2. Build transaction object manually - every field is critical
  const transaction = {
    nonce: nonce,                         // Transaction counter for this account
    gasPrice: '20000000000',              // 20 Gwei in wei (gas price)
    gasLimit: 21000,                      // Gas limit for ETH transfer
    to: toAddress,                        // Recipient address
    value: parseEther(amount),            // Convert ETH to wei (1 ETH = 10^18 wei)
    data: '0x',                           // Empty data for simple ETH transfer
    chainId: 11155111                     // Sepolia testnet identifier
  };
  
  // 3. Sign transaction manually with private key
  // This creates cryptographic proof that you own the sending address
  const signedTx = await signTransactionManually(transaction, privateKey);
  
  // 4. Broadcast to network manually - send to all connected peers
  // This is where the transaction enters the blockchain mempool
  const txHash = await broadcastTransactionManually(signedTx);
  
  // Return transaction hash for user to track on block explorer
  return { transactionHash: txHash };
}

// Manual transaction broadcasting - submit to blockchain network
async function broadcastTransactionManually(signedTx) {
  // Send signed transaction to RPC endpoint
  // This submits the transaction to the network for mining
  const response = await fetch(rpcUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      jsonrpc: '2.0',                     // JSON-RPC protocol version
      method: 'eth_sendRawTransaction',   // Ethereum method to send transaction
      params: [signedTx],                 // Signed transaction data
      id: 1                               // Request ID
    })
  });
  
  // Parse response from blockchain node
  const data = await response.json();
  
  // Check for errors (invalid transaction, insufficient gas, etc.)
  if (data.error) throw new Error(data.error.message);
  
  // Return transaction hash for tracking
  return data.result;
}

Manual Transaction Status & Error Handling (JavaScript)

Handle transaction submission, success/failure responses, and user feedback manually. This includes processing form data, managing transaction flows, and displaying results - all implemented without high-level wallet abstractions.

// Manual transaction submission and result handling
export const action = async ({ request }) => {
  const formData = await request.formData();
  const amount = formData.get('amount')?.toString() || '0';
  const toAddress = formData.get('toAddress')?.toString() || '';
  const fromAddress = formData.get('fromAddress')?.toString() || '';
  const privateKey = formData.get('privateKey')?.toString() || '';

  try {
    // Manual transaction validation before sending
    await validateTransaction(fromAddress, toAddress, amount, privateKey);
    
    // Manual raw transaction creation and sending
    const tx = await sendRawTransaction(privateKey, fromAddress, toAddress, amount);
    
    return json({ 
      success: true, 
      txHash: tx.transactionHash 
    });
  } catch (error) {
    return json({ 
      success: false, 
      error: error.message 
    });
  }
};

// Manual transaction status display
{actionData && (
  <div className={`transaction-result ${actionData.success ? 'success' : 'error'}`}>
    {actionData.success ? (
      <div>
        <h3>Transaction Sent!</h3>
        <p>Transaction Hash: {actionData.txHash}</p>
        <a href={`https://sepolia.etherscan.io/tx/${actionData.txHash}`}>
          View on Etherscan
        </a>
      </div>
    ) : (
      <div>
        <h3>Transaction Failed</h3>
        <p>Error: {actionData.error}</p>
      </div>
    )}
  </div>
)}

Security Considerations

âš ī¸ Critical Security Notice

  • ONLY use throwaway private keys that you are willing to discard after completing the assignment
  • Only test on testnets - never use mainnet for development
  • Never commit private keys to version control
  • Use environment variables or secure key management for testing
  • Implement proper input validation for all user inputs
  • Be aware of reentrancy and other smart contract vulnerabilities

Learning Objectives

By completing this assignment, you will gain deep understanding of:

Transaction Mechanics

How Ethereum transactions work at the protocol level, including field meanings and validation rules.

Cryptographic Signing

ECDSA signature process, message hashing, and recovery mechanisms used in blockchain.

Gas Economics

Gas calculation, fee markets, and optimization strategies for transaction costs.

Network Communication

JSON-RPC protocols, node communication, and transaction broadcasting mechanisms.

Wallet Development Preview

Here's a sneak peek at what your completed wallet application will look like - showing the clean, professional interface that results from implementing manual transaction handling:

Cappy Wallet interface preview showing clean, professional design

Professional wallet interface built with manual transaction handling implementation

Implementation Steps

1

Set up Development Environment

Configure your development environment with necessary cryptographic libraries and testnet access.

2

Implement Account Management

Create functions to manage accounts, track nonces, and handle key generation securely.

3

Build Gas Estimation System

Implement gas calculation logic for different transaction types and network conditions.

4

Create Transaction Builder

Develop the core transaction construction, signing, and serialization functionality.

5

Test and Validate

Thoroughly test your wallet with various transaction types on testnets.

Advanced Challenges

Once you've implemented the basic wallet functionality, consider these advanced features:

  • Multi-signature support: Implement transactions requiring multiple signatures
  • EIP-1559 transactions: Support for modern gas fee mechanisms
  • Contract interaction: Handle ABI encoding for smart contract calls
  • Transaction batching: Optimize gas costs by batching multiple operations
  • Recovery mechanisms: Implement wallet recovery and backup systems

Development Success

Your completed Cappy Wallet implementation demonstrates the power of understanding blockchain technology at the protocol level - creating professional wallet software without relying on high-level abstractions:

Completed Cappy Wallet application

Completed Cappy Wallet showcasing manual implementation of core cryptocurrency functionality

Expected Outcomes

After completing this assignment, you should have:

  • A working cryptocurrency wallet that can send transactions
  • Deep understanding of Ethereum transaction structure
  • Experience with cryptographic signing and verification
  • Knowledge of gas optimization techniques
  • Appreciation for the complexity abstracted by wallet libraries

Common Pitfalls to Avoid

âš ī¸ Watch Out For These Issues

  • Nonce desynchronization: Always verify nonce with network state
  • Insufficient gas estimation: Include buffer for gas limit calculations
  • Improper signature encoding: Follow EIP-155 for chain-specific signatures
  • Hardcoded values: Make gas prices and limits configurable
  • Poor error handling: Implement robust error handling for network issues

Ready to Start?

This is a challenging but highly educational project that will give you invaluable insights into how cryptocurrency wallets work under the hood. Take your time, test thoroughly, and don't hesitate to consult the Ethereum documentation for technical specifications.

Remember: The goal is learning, not production use. Keep security best practices in mind throughout your implementation.

Comments

Loading comments...

Leave a Comment