Skip to content

Understanding Crypto Wallets

💡
Before you start

You need a computer with Python 3 and one library. Python is already installed on macOS and on every Linux system; on Windows, get it from python.org and tick “Add python.exe to PATH” in the installer. Then open a terminal (Windows: Command Prompt; macOS: Terminal) and run pip install cryptography once. Check both are ready with python3 -c "import cryptography; print(cryptography.__version__)" — any version number, such as 41.0.7, means you are set.

You need no cryptocurrency, no exchange account and no internet connection for this. Every key below is made on your own machine, holds nothing, and is thrown away at the end. That is the point: you are going to build the thing a wallet app builds, so you can see exactly what it is holding on your behalf.

What a Crypto Wallet Actually Is

A common misconception is that crypto wallets "store" your cryptocurrency. They do not. Your cryptocurrency exists on the blockchain — it never leaves. What a wallet stores is your private key, which gives you the ability to sign transactions and prove ownership of the coins associated with your address.

Think of it this way: the blockchain is a public ledger that says "address X holds 0.5 BTC." Your wallet holds the private key that proves you control address X. Without that key, the coins are inaccessible to everyone, including you.

How Wallets Generate Addresses

When you create a new wallet, the following happens:

  • Step 1: The wallet generates a random private key (or derives one from a seed phrase)
  • Step 2: A public key is mathematically derived from the private key using elliptic curve cryptography
  • Step 3: The public key is hashed to produce your wallet address — the string you share to receive funds

This process is one-way: you can go from private key to public key to address, but you cannot reverse it. Nobody can derive your private key from your public address.

Custodial vs Non-Custodial

This is the most important security distinction in cryptocurrency:

Custodial Wallet A third party (usually an exchange like Coinbase or Binance) holds your private keys. You access your funds through their platform. Convenient, but you are trusting them with your assets. If they are hacked, go bankrupt, or freeze your account, you lose access.
Non-Custodial (Self-Custody) Wallet You hold your own private keys. No third party can freeze, seize, or lose your funds. But you are entirely responsible for security — there is no "forgot password" option.
⚠️
Not your keys, not your coins

This is not just a slogan. The collapse of FTX in 2022 demonstrated that even major exchanges can fail, taking billions in customer funds with them. If you do not control the private keys, you do not truly own the cryptocurrency.

Types of Wallet Software

  • Desktop wallets — software installed on your computer (e.g., Electrum, Exodus). Your keys are stored locally.
  • Mobile wallets — apps on your phone (e.g., BlueWallet, Trust Wallet). Convenient for everyday transactions but vulnerable if your phone is compromised.
  • Browser extension wallets — browser plugins (e.g., MetaMask). Required for interacting with DeFi and dApps. Higher risk because browsers are a major attack surface.
  • Hardware wallets — dedicated physical devices (e.g., Ledger, Trezor). Store keys offline. The most secure option for significant holdings.
  • Paper wallets — private keys printed on paper. Secure from digital attacks but vulnerable to physical damage, loss, or theft.

Why Controlling Your Own Keys Matters

Self-custody gives you:

  • Censorship resistance — no third party can freeze or block your transactions
  • Counterparty risk elimination — you are not exposed to an exchange being hacked or going bankrupt
  • True ownership — your assets exist independently of any company or service
  • Privacy — no KYC requirements or transaction surveillance by a custodian

The tradeoff is responsibility. You must protect your private keys and seed phrase. There is no recovery mechanism if you lose them.

💡
Start small with self-custody

If you are new to self-custody, start by transferring a small amount to a non-custodial wallet. Practice sending and receiving before moving larger amounts. Get comfortable with the process before it matters.

Now Build a Wallet Yourself, in Five Steps

The fastest way to stop believing that a wallet “stores coins” is to build one and look inside it. In the next ten minutes you will create a real key pair, sign a payment instruction with it, verify that signature using nothing but the public half, throw the whole wallet away and get it back from a single number — and then fail, on purpose, to spend from an address you do not hold the key for. Every line of output shown below came from actually running these commands.

⚠️
This practice wallet is deliberately guessable — never put money in one

To make sure your output matches this page exactly, the key below is built from a phrase printed here in public. Anyone reading this page can regenerate it. Wallets made from a human-chosen phrase are called brain wallets, and they have been emptied by automated scanners within seconds of being funded. A real wallet uses randomness from your operating system, which is what your wallet app does when it says “generating keys”.

1
Make a wallet: one secret number, one public name

Go: open your terminal and move to a folder you can write in, for example cd ~/Desktop (Windows: cd %USERPROFILE%\Desktop).

Do: create a file called make_wallet.py with the seven lines below, then run it with python3 make_wallet.py.

import hashlib
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization

secret_number = hashlib.sha256(b"finkatana practice wallet").digest()
key = Ed25519PrivateKey.from_private_bytes(secret_number)
pub = key.public_key().public_bytes(serialization.Encoding.Raw,
                                    serialization.PublicFormat.Raw)

print("private key (the secret) :", secret_number.hex())
print("public key  (shareable)  :", pub.hex())
print("address     (your name)  :", hashlib.sha256(pub).hexdigest()[:40])

You should see: three lines, exactly these, because the secret was fixed rather than random:

private key (the secret) : ee9a6b35720794e3d837f4a2dabffc1c0687774b88370dd75d9c8e583764531d
public key  (shareable)  : ebaa7effecb663c5cd79bca56e49886a1384b9db54b558f902aca46cc99b712e
address     (your name)  : 195b7b960a55aecbf024587164b5ad587d4ba91c

Look at what you just made: three numbers and nothing else. No balance, no coins, no ledger. The private key is a 256-bit number. The public key was calculated from it. The address is a hash of the public key, shortened. Your wallet app does exactly this, then hides all three behind a friendly screen.

If not: ModuleNotFoundError: No module named 'cryptography' means the library did not install — run pip install cryptography again and read its last line for the real error. python3: command not found on Windows means you should type python instead. If your three lines differ from the ones above, the quoted phrase inside b"..." is not identical — check for a missing space or a capital letter.

2
Sign a payment instruction with the private key

Go: same folder, same terminal.

Do: create sign.py and run it with python3 sign.py. This is the one action a private key exists for.

import hashlib
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

secret_number = hashlib.sha256(b"finkatana practice wallet").digest()
key = Ed25519PrivateKey.from_private_bytes(secret_number)

message = b"Pay 5 coins to Alice"
signature = key.sign(message)
print("message   :", message.decode())
print("signature :", signature.hex())

You should see: your instruction, and a 128-character signature over it:

message   : Pay 5 coins to Alice
signature : 99c8bbd8c58c1fd61d724fbe59980359ddaa0e18555c7d533e49172d8fa357fe814650dd6c0433982f652ea38ec562f93c01e45ef3a754ddc34cba94c0112101

That signature is a transaction, stripped to its essence. When you tap Confirm in a wallet app, this is the step it performs — and it is the only step that needs your secret.

If not: if the signature you get is different, your message line differs by a character; signatures cover the message exactly, including capitals and spaces. If Python reports TypeError: a bytes-like object is required, the b before the opening quote is missing.

3
Verify it with the public half only — and watch a tampered amount get rejected

Go: same folder. Think of this file as the network: it never sees your secret.

Do: create verify.py, paste it exactly, and run python3 verify.py. Note that the only wallet material in it is the public key from step 1.

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature

pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(
    "ebaa7effecb663c5cd79bca56e49886a1384b9db54b558f902aca46cc99b712e"))
sig = bytes.fromhex(
    "99c8bbd8c58c1fd61d724fbe59980359ddaa0e18555c7d533e49172d8fa357fe"
    "814650dd6c0433982f652ea38ec562f93c01e45ef3a754ddc34cba94c0112101")

for message in [b"Pay 5 coins to Alice", b"Pay 5000 coins to Alice"]:
    try:
        pub.verify(sig, message)
        print("ACCEPTED :", message.decode())
    except InvalidSignature:
        print("REJECTED :", message.decode())

You should see: the original accepted, the inflated amount refused:

ACCEPTED : Pay 5 coins to Alice
REJECTED : Pay 5000 coins to Alice

Nobody had to trust you, and nobody needed your secret. That is the whole trick a blockchain runs on: thousands of strangers can check your instruction is genuine while being completely unable to forge one.

If not: ValueError: non-hexadecimal number found means a character was dropped when you copied the long hex strings — they must be exactly 64 and 128 characters. If both lines say REJECTED, the public key and the signature came from different runs; re-copy both from your own step 1 and step 2 output.

4
Destroy the wallet, then bring it back from the number alone

Go: same folder. Delete every file you made: rm make_wallet.py sign.py (Windows: del make_wallet.py sign.py). You have just done to yourself what a stolen laptop, a wiped phone or a dead hard drive does.

Do: create restore.py and run it. It reconstructs the wallet from the secret — and then does it again with one extra letter in the phrase.

import hashlib
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization

def address_from(phrase):
    n = hashlib.sha256(phrase).digest()
    pub = Ed25519PrivateKey.from_private_bytes(n).public_key().public_bytes(
        serialization.Encoding.Raw, serialization.PublicFormat.Raw)
    return hashlib.sha256(pub).hexdigest()[:40]

print("same secret again :", address_from(b"finkatana practice wallet"))
print("one letter wrong  :", address_from(b"finkatana practice wallets"))

You should see: the first address identical to step 1, the second a complete stranger:

same secret again : 195b7b960a55aecbf024587164b5ad587d4ba91c
one letter wrong  : 7273e969b46c9917bd3844d01c749fc288cbd3fe

Two lessons in two lines. The wallet software was disposable — the secret rebuilt everything. And a single wrong character did not produce an error message; it produced a different, perfectly valid, completely empty wallet. That silent failure is why a recovery phrase must be copied character by character and then tested.

If not: if the first line does not match step 1, the phrase inside b"..." is not identical — compare it word by word, including the space before practice.

5
Try to spend from an address you do not hold the key for

Go: same folder. This step is meant to fail, and the failure is the lesson.

Do: create attack.py with the public key from step 1 and try to sign with it, as a thief who has only seen your address would have to.

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(
    "ebaa7effecb663c5cd79bca56e49886a1384b9db54b558f902aca46cc99b712e"))

print("public key loaded:", type(pub).__name__)
pub.sign(b"Pay 5 coins to Mallory")

You should see: the key loads perfectly, then refuses to sign, because the ability is simply not there:

public key loaded: Ed25519PublicKey
Traceback (most recent call last):
  File "attack.py", line 7, in <module>
    pub.sign(b"Pay 5 coins to Mallory")
    ^^^^^^^^
AttributeError: 'cryptography.hazmat.bindings._rust.openssl.ed25519.Ed25519PublicKey' object has no attribute 'sign'

This is why publishing your receiving address is safe, and why an exchange asking for your address is a normal request while anything asking for your recovery phrase is theft in progress. Your address is the public half. It can be given to the world.

If not: if you see no traceback at all, the last line was not saved — the file must end with the pub.sign(...) call. The long dotted class name in the last line varies between library versions; what matters is the words has no attribute 'sign'.

🎉
Check yourself before moving on

Without scrolling up: in step 4 one extra letter produced a different address and no error message at all. If a person restoring a real wallet mistypes one word of their recovery phrase, what will they see on screen, and why is that more dangerous than a crash? Answer: they will see a working wallet with a zero balance — a valid but different wallet, not an error. It is more dangerous because the natural conclusion is “my coins are gone” or “this app is broken”, so people panic, retype the phrase into the first “recovery service” they find, and hand the real phrase to a thief. The correct response is to check the phrase again, character by character, against the original.

Now do it without the page: change the phrase in restore.py to a sentence only you would pick, run it, and write the resulting address down. Close the terminal, delete the file, then reconstruct the same address from memory of your sentence alone. If you cannot, you have just learned — for free, with nothing at stake — exactly why recovery phrases are written on paper rather than remembered.

Summary

  • Wallets store private keys, not cryptocurrency — the coins live on the blockchain
  • Addresses are derived from private keys through one-way cryptographic functions
  • Custodial wallets are convenient but expose you to counterparty risk
  • Non-custodial wallets give you full control but full responsibility
  • Different wallet types offer different security and convenience tradeoffs
🎉
You now understand how crypto wallets work!

Next, learn about the differences between hot and cold wallets to choose the right security level for your needs.