Building a self-custodial Ethereum wallet is a powerful way to gain hands-on experience with blockchain development. In this guide, you’ll learn how to create a simple yet functional Ethereum wallet application using ethers.js for blockchain interactions and SolidJS for building a responsive frontend. By the end, you'll have a working wallet that supports creation via mnemonic phrase, balance checking, and ETH transfers—perfect for learning or extending into a browser extension.
Understanding Mnemonic Phrases
A mnemonic phrase—also known as a seed phrase or recovery phrase—is a human-readable representation of a wallet’s private key. It typically consists of 12 or 24 randomly generated words that encode cryptographic information used to derive all keys in a hierarchical deterministic (HD) wallet.
This phrase is essential because it allows users to recover their wallet and access funds even if they lose device access. In our app, we’ll generate this phrase during setup and prompt the user to store it securely—emphasizing that anyone with this phrase can take full control of the wallet.
Setting Up the Project
We’ll use Vite to bootstrap a fast, modern development environment with SolidJS and TypeScript, ensuring type safety and excellent developer experience.
Open your terminal and run:
npm create vite@latest my-wallet -- --template solid-tsOnce the project is created, navigate into the directory:
cd my-walletThen start the development server after installation:
npm run devYou now have a working SolidJS TypeScript app ready for wallet integration.
Installing Required Dependencies
To interact with Ethereum and handle encryption, install two key packages:
npm i ethers crypto-js- ethers.js: A lightweight library for interacting with Ethereum wallets and networks.
- crypto-js: Enables AES encryption to securely store the private key in the browser.
👉 Learn how real-world crypto wallets manage security at scale.
Building the Wallet Interface
We'll structure our app using SolidJS signals to manage state across multiple steps: password setup, wallet creation, phrase display, and balance/transfer functionality.
Start by setting up the core signals in your App.tsx:
import { createSignal } from 'solid-js';
import { Wallet, HDNodeWallet } from 'ethers';
function App() {
const [step, setStep] = createSignal(1);
const [password, setPassword] = createSignal('');
const [phrase, setPhrase] = createSignal('');
const [wallet, setWallet] = createSignal<HDNodeWallet | null>(null);
// UI JSX will go here
}These signals track:
- Current setup step
- User-entered password
- Generated recovery phrase
- Active wallet instance
Step 1: Create or Load Wallet
On initial load, check if an encrypted private key exists in localStorage. If so, prompt the user to enter their password to decrypt and restore the wallet.
const key = localStorage.getItem('encryptedPrivateKey');If no key exists, show a form to set a password and create a new wallet:
<>
<h2>Secure Your Wallet</h2>
<input
type="password"
placeholder="Enter password"
onInput={(e) => setPassword(e.target.value)}
/>
<button onClick={() => (key ? loadWallet() : createWallet())}>
{key ? 'Load Wallet' : 'Create Wallet'}
</button>
</>👉 See how top-tier platforms ensure secure key management.
Generating the Wallet and Mnemonic
The createWallet function does several things:
- Generates a random mnemonic.
- Derives an HD wallet from it.
- Connects the wallet to an Ethereum provider.
- Encrypts the private key with the user’s password.
- Stores it in
localStorage. - Advances to the next step.
Here’s the implementation:
import { JsonRpcProvider } from 'ethers';
import CryptoJS from 'crypto-js';
const provider = new JsonRpcProvider('https://sepolia.infura.io/v3/YOUR_API_KEY');
const createWallet = () => {
const mnemonic = Wallet.createRandom().mnemonic;
setPhrase(mnemonic.phrase);
const wallet = HDNodeWallet.fromMnemonic(mnemonic!);
wallet.connect(provider);
setWallet(wallet);
encryptAndStorePrivateKey();
setStep(2);
};
const encryptAndStorePrivateKey = () => {
const encryptedPrivateKey = CryptoJS.AES.encrypt(
wallet()!.privateKey,
password()
).toString();
localStorage.setItem('encryptedPrivateKey', encryptedPrivateKey);
};🔒 Security Note: Never expose your Infura or Alchemy API keys in production. Use environment variables or backend proxies.
Step 2: Display Recovery Phrase
After generating the wallet, show the mnemonic phrase and instruct the user to back it up securely:
<>
<h2>Backup Your Recovery Phrase</h2>
<p>Write down these words in order. Do not share them with anyone.</p>
<div>{phrase()}</div>
<button onClick={() => setStep(3)}>I've Saved It</button>
</>This step is critical—losing the phrase means losing access forever.
Step 3: View Balance & Send ETH
Now that the wallet is ready, display the address and balance. First, add more signals:
const [balance, setBalance] = createSignal('0');
const [recipientAddress, setRecipientAddress] = createSignal('');
const [amount, setAmount] = createSignal('');
const [etherscanLink, setEtherscanLink] = createSignal('');Fetch the balance when loading the wallet:
const loadWallet = async () => {
const bytes = CryptoJS.AES.decrypt(key!, password());
const privateKey = bytes.toString(CryptoJS.enc.Utf8);
const w = new Wallet(privateKey, provider);
setWallet(w);
const balance = await w.provider.getBalance(w.address);
setBalance(formatEther(balance));
setStep(3);
};Display wallet info:
<>
<p>Address: {wallet()?.address}</p>
<p>Balance: {balance()} ETH</p>
<input
type="text"
placeholder="Recipient Address"
onInput={(e) => setRecipientAddress(e.target.value)}
/>
<input
type="number"
placeholder="Amount (ETH)"
onInput={(e) => setAmount(e.target.value)}
/>
<button onClick={transfer}>Send ETH</button>
{etherscanLink() && (
<a href={etherscanLink()} target="_blank">View on Etherscan</a>
)}
</>Sending Transactions
Use sendTransaction from ethers.js to transfer ETH:
import { parseEther } from 'ethers';
const transfer = async () => {
try {
const tx = await wallet()!.sendTransaction({
to: recipientAddress(),
value: parseEther(amount()),
});
setEtherscanLink(`https://sepolia.etherscan.io/tx/${tx.hash}`);
} catch (error) {
console.error('Transaction failed:', error);
alert('Transaction rejected or failed.');
}
};This sends a signed transaction directly through the connected provider.
Frequently Asked Questions
Can I use this wallet on mainnet?
Yes—but only after replacing the Sepolia provider URL with a mainnet endpoint (e.g., https://mainnet.infura.io/v3/YOUR_KEY). Be extremely cautious: mainnet uses real ETH.
Is storing encrypted keys in localStorage safe?
For learning purposes, yes—but not ideal for production. Consider hardware wallets, secure enclaves, or encrypted databases for real applications.
What happens if I forget my password?
There is no recovery. The private key cannot be decrypted without the correct password. Always remember your password or store it securely.
Can I recover my wallet with the mnemonic?
Absolutely. You can extend this app with a "Restore Wallet" feature using:
function recoverWalletFromPhrase(phrase: string) {
return HDNodeWallet.fromMnemonic(phrase).connect(provider);
}Why use SolidJS instead of React?
SolidJS offers fine-grained reactivity without virtual DOM overhead, making it faster and more efficient for real-time updates like balance tracking.
How do I turn this into a browser extension?
Package the app as a Chrome extension using Manifest V3. Store secrets in chrome.storage.secure and inject scripts into dApps for signing—similar to MetaMask’s architecture.
What’s Next?
Now that you’ve built a basic Ethereum wallet, consider these enhancements:
- Add token support (ERC-20)
- Implement network switching (Mainnet, Polygon, etc.)
- Integrate QR code scanning for addresses
- Build transaction history view
- Deploy as a Chrome extension
👉 Explore advanced wallet architectures and security patterns.
With this foundation, you're well on your way to building secure, user-friendly decentralized applications. Keep iterating, stay secure, and happy coding!