Build a Basic Crypto Wallet in Solidity

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:
Private Key Derivation: Your private key is used to manually derive your wallet address using cryptographic functions
Manual Transaction Creation: All transactions are manually created and signed without wallet libraries like ethers.js
Manual Nonce & Gas Management: Nonce tracking and gas estimation are implemented from scratch
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

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

Test wallet option for safe development and testing
3. Private Key Entry
Secure private key management - the core of wallet functionality:

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

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

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 calculateto&value: Recipient address and ETH amountv, 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:

Professional wallet interface built with manual transaction handling implementation
Implementation Steps
Set up Development Environment
Configure your development environment with necessary cryptographic libraries and testnet access.
Implement Account Management
Create functions to manage accounts, track nonces, and handle key generation securely.
Build Gas Estimation System
Implement gas calculation logic for different transaction types and network conditions.
Create Transaction Builder
Develop the core transaction construction, signing, and serialization functionality.
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 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.