Creating a Basic Ethereum Wallet App with Ethers.js and SolidJS

·

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-ts

Once the project is created, navigate into the directory:

cd my-wallet

Then start the development server after installation:

npm run dev

You 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

👉 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:


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:

  1. Generates a random mnemonic.
  2. Derives an HD wallet from it.
  3. Connects the wallet to an Ethereum provider.
  4. Encrypts the private key with the user’s password.
  5. Stores it in localStorage.
  6. 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:

👉 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!