Anatomy of an Ethereum Wallet
When it comes to naming conventions related to technology, I think we have made poor choices over time. Just think about the fact that when you "visit" a website, or "navigate to" a website, what actually happens is the opposite: it is the website’s front-end code that gets delivered to your browser!
Ethereum "wallets" are no exception to this poor naming convention. Many people probably think that wallets contain money (or coins, in this case), while your ether is actually on the blockchain, and your wallet only contains the private key(s) to manage it. So yes, probably keychain would have been a much more appropriate name for Ethereum wallets, but I guess it is too late to change that.
In this post, we are going to see how a basic Ethereum wallet works, try to code one from scratch, and understand a critical piece of the ETH ecosystem.
Type of Wallets
In Ethereum, there are two primary types of wallets:
- Non-deterministic Wallets: where each key is independently generated from a random number.
- Deterministic Wallets: where multiple keys are generated from a single seed, along with mnemonic codes that can be used to reconstruct the wallet and limit the risk of data loss.
Nowadays, non-deterministic wallets are considered a legacy technology, used only for development and testing purposes, while hierarchical deterministic wallets are the de-facto standard due to enhanced safety and the ability to generate multiple correlated keys useful for organizational management.
Still, we will start coding a non-deterministic wallet, because it's probably the best way to understand the underlying technology due to its simplicity.
As we said, in its simplest form, a wallet is mostly a keychain, holding one or more private keys used to sign transactions and broadcast them to the Ethereum network. So, as long as we have a program that can safely generate and store the private key, we have the base for an Ethereum wallet!
One of the easiest ways to create a wallet would be using Python and the official eth_account library:
from eth_account import Account
random_account = Account.create()
print("Public key:", random_account._key_obj.public_key.to_hex())
print("Private key:", random_account.key.hex())
print("Address:", random_account.address)
Which would return something like:
Public key: 0x5fe300028842cdc6bc5b6dc811064a8d36de7598d48adc1cddaf5f3c1028f9de5309fc02c5adf84781f2cd925ededba3444244b86e84e487532085ccf80f2fd7
Private key: b0492d80667af49796cfbe15d846ab*****REDACTED*****
Address: 0x9dba5b524e82D82028382838deeC6DD594C15ed3
But this clearly has a probably over-abstracted API that hides all the logic behind a simple Account.create(). So, let's try to stick to Python and write some code without using eth_account:
import os
from ecdsa import SigningKey, SECP256k1
from eth_hash.auto import keccak
# random private key
private_key = os.urandom(32)
sk = SigningKey.from_string(private_key, curve=SECP256k1)
# public key (64 bytes)
public_key = sk.verifying_key.to_string()
# last 20 bytes of Keccak-256 (40 hex chars)
address_bytes = keccak(public_key)[-20:] # last 20 digits
print("Public key:", "0x" + public_key.hex())
print("Private key:", "0x" + private_key.hex())
print("Address:", "0x" + address_bytes.hex())
Ok, this is more interesting already! We imported some cryptographic libraries and:
- generated a random private key using "real" OS-level randomness
- created a SigningKey needed to generate the corresponding public key
- calculated the public key associated with the private key
- calculated the Ethereum address, which is basically the last 20 bytes of the Keccak-256 hash of the public key (Keccak-256 is the algorithm, which itself has an interesting story and would deserve its own blog post)
The code is still straightforward, but at least we got an idea of a piece of the logic behind the public and private key. One common misconception is that an address has to be registered somewhere to be used, which is not true. An Ethereum address is just a mathematical object, and even if it's generated on your local machine and never touched the internet, it can be used to send ether on the blockchain, which I just did: https://etherscan.io/tx/0xbb10aefb3229066b7a8e0587324f692906f014e7d587ee8c56963954ec646fa0
As you see, the transaction is public on the blockchain, but only the holder of the corresponding private key can control the address and withdraw or send the ether somewhere else.
As a proof, let's go back to the official Ethereum implementation and generate a wallet by providing the private key ourselves:
from eth_account import Account
private_key_hex = "YOUR_PRIVATE_KEY"
account_from_private = Account.from_key(private_key_hex)
print("Address:", account_from_private.address)
The wallet address will match exactly, and that is the one and only key that can generate that address!
Now let's try to level up the game and create the same key generator in C.
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <secp256k1.h>
#include "keccak256.h"
void to_hex(const uint8_t *in, size_t len, char *out) {
static const char *hex = "0123456789abcdef";
for (size_t i = 0; i < len; i++) {
out[2*i] = hex[(in[i] >> 4) & 0xF];
out[2*i+1] = hex[in[i] & 0xF];
}
out[2*len] = '\0';
}
int main() {
// 1) Create secp256k1 context
secp256k1_context *ctx = secp256k1_context_create(SECP256K1_CONTEXT_SIGN);
// 2) Generate random 32-byte private key
uint8_t privkey[32];
do {
FILE *fp = fopen("/dev/urandom", "rb");
fread(privkey, 1, 32, fp);
fclose(fp);
} while (!secp256k1_ec_seckey_verify(ctx, privkey));
// 3) Derive public key
secp256k1_pubkey pubkey;
if (!secp256k1_ec_pubkey_create(ctx, &pubkey, privkey)) {
fprintf(stderr, "Failed to create public key\n");
return 1;
}
// 4) Serialize uncompressed public key (65 bytes)
uint8_t pub_uncompressed[65];
size_t pub_len = 65;
secp256k1_ec_pubkey_serialize(ctx, pub_uncompressed, &pub_len, &pubkey, SECP256K1_EC_UNCOMPRESSED);
// Drop prefix 0x04
uint8_t pub_bytes[64];
memcpy(pub_bytes, pub_uncompressed + 1, 64);
// 5) Keccak-256 hash of public key
uint8_t hash[32];
keccak_256(pub_bytes, 64, hash);
// 6) Ethereum address = last 20 bytes
uint8_t address[20];
memcpy(address, hash + 12, 20);
// 7) Print results
char priv_hex[65], pub_hex[129], addr_hex[41];
to_hex(privkey, 32, priv_hex);
to_hex(pub_bytes, 64, pub_hex);
to_hex(address, 20, addr_hex);
printf("Public key: 0x%s\n", pub_hex);
printf("Private key: 0x%s\n", priv_hex);
printf("Address: 0x%s\n", addr_hex);
secp256k1_context_destroy(ctx);
return 0;
}
As you can see, C in its simplest form is lacking some common utilities, like a helper to convert to hex (which I had to code myself) and, most importantly, a keccak256 implementation, which I had to provide and compile — but the output was still the same, as a confirmation that the cryptography is correct.
We followed the same steps as our Python version (random number > private key > public key > address), but dealt with some additional C complexity, like handling the secp256k1 context. Of course this is not production code, but a valid PoC to deep dive into Ethereum wallet creation. Plus, compiled as a binary and run on an embedded device without an internet connection, it would be a very simple form of "cold wallet" — really cool, uh?!
Once the private key is generated, the next challenge is to store it safely, retrieve it, and use it while keeping it super secure. This goes a bit beyond the scope of this blog post, but it is worth clarifying that if you manage a wallet yourself, the private key is everything. If someone gets to know it, they own your wallet, and there is nothing else to do other than move your coins somewhere else. The same applies if you lose the key — your ether is lost forever — and that is why many people choose to use custodial services and delegate the complexity, while accessing their wallet with a username and password (a regular account).
Hierarchical Deterministic Wallets
Until now we have seen the dynamics of a wallet that is basically not in use anymore, but the core concepts (the encryption, the algorithm) are still absolutely valid.
Hierarchical Deterministic (HD) Wallets are the current standard — they are much more complex, but also more secure and feature‑rich. Instead of a simple private/public key architecture, in HD Wallets we have a seed (itself generated from a word list called Mnemonic Codes) and a master key (private), which can generate a set of child keys, each of which can generate a set of grandchild keys, and so on.
By knowing the mnemonic words (or even just the seed or the master key), the wallet can be fully reconstructed — and that’s why it is called deterministic. Every private key is isolated — this doesn’t mean it shouldn’t be handled safely, but still, it’s like a “wallet inside a wallet”.
But the most powerful feature of HD Wallets is probably the ability to generate public keys and addresses without knowing any private keys! This makes the wallet suitable for a variety of less secure purposes, like server‑side wallet generation, e‑commerce, and dynamic wallets.
Ok, enough talk... let's code!
from eth_account import Account
# HD wallet support in eth-account is marked "unaudited" and must be enabled explicitly
Account.enable_unaudited_hdwallet_features()
account, mnemonic = Account.create_with_mnemonic(num_words=12)
print("Mnemonic:", mnemonic)
print("Primary address:", account.address)
print("Primary private key:", account.key.hex())
One more time, Python was straightforward but over-abstracted, in the sense that it hides all the logic that generates the mnemonic codes, the seed, and the master private key. We will do a better example later, but for now let's stick to the eth_account library to reinforce all the features that make an HD Wallet so powerful.
from eth_account import Account
# HD wallet support in eth-account is marked "unaudited" and must be enabled explicitly
Account.enable_unaudited_hdwallet_features()
account, mnemonic = Account.create_with_mnemonic(num_words=12)
print("Mnemonic:", mnemonic)
for i in range(3):
path = f"m/44'/60'/0'/0/{i}"
acct = Account.from_mnemonic(mnemonic, account_path=path)
print(f"\nChild {i}")
print("Path:", path)
print("Address:", acct.address)
print("Private key:", acct.key.hex())
Will give us something like:
Mnemonic: believe dog usual display illegal rocket stem six waste depend apple letter
Child 0
Path: m/44'/60'/0'/0/0
Address: 0xFebc74Ff57513368371f32a41B383E5fc4AC3eeC
Private key: bb32a93d5b10780c720a2e26e4beb71c3418237dda2947648323ef96076ac989
Child 1
Path: m/44'/60'/0'/0/1
Address: 0x3D059AA584a206426323aa80Bf8e517Ed6509d55
Private key: 61d4d891b2697369085893db698df25d96fabee06e98a69a7230fd359fd68886
Child 2
Path: m/44'/60'/0'/0/2
Address: 0x496d9c211211b45f2CF899D2804Eb051367A2546
Private key: a5f796018f4ab982aebda2845a81d54278bd4ebf63682fa8d7df2604d2fe5932
I know this may seem to make no sense and is unreadable — path = f"m/44'/60'/0'/0/{i}" — so here is a table that explains the syntax:
m / purpose' / coin_type' / account' / change / address_index
- m → master root (from seed)
- purpose' → type of wallet (44' = standard, 49'/84' = other formats)
- coin_type' → which coin (0' = BTC, 60' = ETH)
- account' → account number (0', 1', 2', …)
- change → 0 = external, 1 = change/internal (Ethereum usually 0)
- address_index → which address in this account (0, 1, 2, …)
m # master node (root of the wallet) ├─ 44' # purpose (BIP44) │ └─ 60' # coin type (Ethereum) │ └─ 0' # account │ ├─ 0 # change: 0=external, 1=internal │ │ ├─ 0 # address index (child 0) │ │ ├─ 1 # address index (child 1) │ │ └─ 2 # address index (child 2) │ └─ 1 # internal/change addresses
The same deterministic derivation can be done with a special type of extended public and private keys that allow child key derivation without full wallet access (like the mnemonic codes). Now let's try to build the same thing in a more explicit way:
from mnemonic import Mnemonic
import bip32utils
from eth_utils import keccak, to_checksum_address
mnemo = Mnemonic("english")
mnemonic = mnemo.generate(strength=128)
print("Mnemonic:", mnemonic)
seed = mnemo.to_seed(mnemonic, passphrase="")
print("Seed (hex):", seed.hex())
root_key = bip32utils.BIP32Key.fromEntropy(seed)
# path breakdown: 44' (purpose) / 60' (ETH coin) / 0' (account) / 0 (change) / 0 (address index)
purpose = root_key.ChildKey(44 + bip32utils.BIP32_HARDEN)
coin = purpose.ChildKey(60 + bip32utils.BIP32_HARDEN)
account = coin.ChildKey(0 + bip32utils.BIP32_HARDEN)
change = account.ChildKey(0)
eth_key = change.ChildKey(0)
private_key_hex = eth_key.PrivateKey().hex()
public_key = eth_key.PublicKey()
eth_address = to_checksum_address(keccak(public_key[1:])[-20:])
print("Ethereum Private Key:", private_key_hex)
print("Ethereum Address:", eth_address)
In the above example, the base logic of mnemonic words > seed > key is much more explicit and still correct. It is worth saying that this code is also abstracting a lot of complexity (especially in the words generation), but I don't want to turn this post into a complexity hell. You can dig into the mnemonic library if that is something of interest for you.
It goes without saying: each of the child private keys can be stored and used to sign and broadcast transactions, just like in a simple non‑deterministic wallet. But what about the ability of a public key to generate more child public keys?
Here is a PoC that generates an XPUB (extended public key) and then uses it to generate valid Ethereum addresses without using or knowing any private key.
from bip_utils import (
Bip39MnemonicGenerator, Bip39SeedGenerator,
Bip44, Bip44Coins, Bip44Changes
)
def create_eth_xpub():
"""
Create a 12-word mnemonic and return account-level XPUB.
"""
mnemonic = Bip39MnemonicGenerator().FromWordsNumber(12)
seed = Bip39SeedGenerator(mnemonic).Generate()
bip44_account = Bip44.FromSeed(seed, Bip44Coins.ETHEREUM).Purpose().Coin().Account(0)
xpub = bip44_account.PublicKey().ToExtended()
return mnemonic, xpub
def derive_eth_addresses(xpub, num_addresses=5, account_index=0, change_type=Bip44Changes.CHAIN_EXT):
"""
Derive Ethereum addresses from an account-level XPUB.
Since XPUB can't store full paths, paths are manually reconstructed.
"""
# Recreate context from XPUB
pub_ctx = Bip44.FromExtendedKey(xpub, Bip44Coins.ETHEREUM)
# Select external or internal/change chain
ext_chain = pub_ctx.Change(change_type)
addresses = []
for i in range(num_addresses):
addr_ctx = ext_chain.AddressIndex(i)
path = f"m/44'/60'/{account_index}'/{0 if change_type==Bip44Changes.CHAIN_EXT else 1}/{i}"
addresses.append({
"index": i,
"path": path,
"address": addr_ctx.PublicKey().ToAddress(),
"pubkey_compressed": addr_ctx.PublicKey().RawCompressed().ToHex()
})
return addresses
mnemonic, xpub = create_eth_xpub()
print("Mnemonic:", mnemonic)
print("Account XPUB:", xpub)
addresses = derive_eth_addresses(xpub, num_addresses=5)
print("\nDerived addresses:")
for a in addresses:
print(f"Index {a['index']}: {a['address']} (Path: {a['path']})")
Mnemonic: wait check hen critic caught palace diagram tired orbit end depend insane
Account XPUB: xpub6Bo1bChqpsujDES8fwWizwqj2Yrduc98om2vMBzYTiH7QTj9n8irxazRUBE3vxU8SghfSuV6D7XC4sKqMUxxytAWpsU73syXcbakCMCsrrT
Derived addresses:
Index 0: 0x04dE4E38f54deD811D4Ab90bbEdecB4f08d76D9E (Path: m/44'/60'/0'/0/0)
Index 1: 0xcBba27a06444BA723c20167f5F2589cdbB69424C (Path: m/44'/60'/0'/0/1)
Index 2: 0x38d9E1BFb24E516f2Df5623690F7354b86c41706 (Path: m/44'/60'/0'/0/2)
Index 3: 0xec476B9C8C47e118c71414668f81271C5f2D8e10 (Path: m/44'/60'/0'/0/3)
Index 4: 0x1121C06bF6f150be4bc4d6613F04c95c89FD3955 (Path: m/44'/60'/0'/0/4)
Those are all valid Ethereum wallets, generated without a private key! Or more precisely, the xpub requires the seed, but the address generation function only takes the xpub as input, so the two processes can be completely isolated, even on different machines with different security protocols.
Let's wrap up!
At this point, maybe it is time to make a recap and list some takeaways from this post:
- An Ethereum wallet is basically a keychain
- It only needs a reliable source of randomness and some mathematics to work
- It generates a private and public key in its simplest form
- The private key is used to sign a transaction, but once signed, a transaction can be propagated without a private key
- The public key is used to derive the wallet address
- A wallet can work offline and can be completely isolated from insecure environments until it has to broadcast a transaction
In this post, we only covered key creation — but of course, a wallet also has to be able to sign transactions and broadcast them, which is something with a good amount of complexity and probably deserves a separate post.
But for now, you may be ready to play around with Ethereum wallets and maybe create your own. In a world where we have “everything as a service”, it feels good to build something from scratch!
Until the next one,
Francesco