Skip to content

How Cryptocurrency Works

💡
Before you start

You need Python 3 and one library. On Windows install Python from python.org with “Add python.exe to PATH” ticked; macOS and Linux have it already. Then run pip install cryptography once and check with python3 -c "import cryptography; print(cryptography.__version__)", which should print a version number.

You are going to write the part of a cryptocurrency that decides whether a payment is valid — the rules every node on the network applies, in about twenty lines. Then you will try to cheat those rules four ways, one of which will succeed, and fixing it will show you why an account’s address is derived from its key rather than chosen.

Digital Money Without a Bank

Traditional money relies on banks and payment processors to verify transactions and prevent double-spending. Cryptocurrency eliminates these intermediaries by using cryptography and a distributed network to achieve the same goals.

When you send cryptocurrency, you are broadcasting a digitally signed message to the network that says: "I authorize the transfer of X amount from my address to this other address." The network then verifies and records this transaction without any bank involvement.

Public and Private Key Cryptography

Every cryptocurrency user has a key pair:

Private Key A secret number that only you know. It proves ownership and authorizes transactions. Losing it means losing access to your funds permanently. Sharing it means anyone can steal your funds.
Public Key Derived mathematically from the private key. It generates your wallet address, which you can share freely for receiving funds. It cannot be used to derive the private key.

When you send a transaction, your wallet software uses your private key to create a digital signature. Anyone on the network can use your public key to verify that the signature is authentic without ever seeing your private key.

⚠️
Your private key is everything

Whoever holds the private key controls the funds. There is no password reset, no customer support, and no account recovery. This is fundamentally different from traditional banking.

Anatomy of a Transaction

A cryptocurrency transaction typically contains:

  • Sender address — the public address sending the funds
  • Recipient address — the public address receiving the funds
  • Amount — how much cryptocurrency to transfer
  • Transaction fee — a small payment to incentivize miners or validators
  • Digital signature — proof that the sender authorized this transaction

Once broadcast, the transaction enters the mempool (memory pool) — a waiting area where unconfirmed transactions sit until a miner or validator includes them in a block.

Mining: Proof of Work

In Proof of Work systems like Bitcoin, miners are computers that compete to solve a mathematical puzzle. The puzzle requires enormous computational effort but is easy to verify once solved.

  • Miners collect pending transactions from the mempool
  • They bundle these transactions into a candidate block
  • They repeatedly hash the block data with different random numbers (nonces) until they find a hash that meets a difficulty target
  • The first miner to find a valid hash broadcasts the block to the network
  • Other nodes verify the block and add it to their copy of the blockchain
  • The winning miner receives a block reward (newly created coins) plus all transaction fees in the block

Staking: Proof of Stake

In Proof of Stake systems like Ethereum, validators lock up their own cryptocurrency as collateral instead of spending electricity on computation.

  • Validators deposit (stake) cryptocurrency into a smart contract
  • The network selects validators to propose new blocks, often weighted by the amount staked
  • Other validators attest that the proposed block is valid
  • Honest validators earn rewards from transaction fees and new coin issuance
  • Dishonest validators have their stake slashed (partially or fully destroyed)
💡
Energy comparison

Proof of Stake uses roughly 99.95% less energy than Proof of Work. This was a major reason Ethereum switched from PoW to PoS in September 2022.

Transaction Fees

Every transaction includes a fee that goes to the miner or validator who processes it. Fees serve two purposes:

  • Incentive — they motivate miners and validators to include your transaction
  • Spam prevention — they make it expensive to flood the network with junk transactions

Fees rise when the network is congested (many people transacting at once) and fall during quiet periods. During peak congestion, a single Bitcoin or Ethereum transaction can cost tens of dollars.

Confirmations

When your transaction is included in a block, it has one confirmation. Each subsequent block added on top adds another confirmation. More confirmations mean the transaction is harder to reverse:

  • 1 confirmation — transaction is in a block but could theoretically be reorganized
  • 3 confirmations — generally considered safe for small transactions
  • 6 confirmations — the Bitcoin standard for large transfers (roughly one hour)
  • 12+ confirmations — exchanges often require this for large deposits

Now Write the Rules a Node Enforces, in Five Steps

A cryptocurrency has no manager deciding which payments count. Instead every node applies the same short list of checks and gets the same answer. In the next twenty-five minutes you will write that list, watch it accept an honest payment, reject a replay and an overspend — and then let a thief straight through, because the version you wrote first contains a real flaw that real systems avoid by design. Every line of output below came from running these files.

1
Write the ledger

Go: open a terminal in a folder you can write to, e.g. cd ~/Desktop (Windows: cd %USERPROFILE%\Desktop).

Do: save this as ledger.py. It prints nothing yet. Read the three checks in apply: a valid signature, a transaction number not used before, and enough coins.

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

class Ledger:
    def __init__(self, balances):
        self.balances = dict(balances)
        self.used_numbers = set()

    def apply(self, tx, signature, public_key):
        message = f"{tx['from']}->{tx['to']}:{tx['amount']}#{tx['number']}".encode()
        try:
            Ed25519PublicKey.from_public_bytes(public_key).verify(signature, message)
        except InvalidSignature:
            return "REJECTED: signature does not match"
        if (tx["from"], tx["number"]) in self.used_numbers:
            return "REJECTED: this transaction number was already used"
        if self.balances.get(tx["from"], 0) < tx["amount"]:
            return "REJECTED: not enough coins"
        self.balances[tx["from"]] -= tx["amount"]
        self.balances[tx["to"]] = self.balances.get(tx["to"], 0) + tx["amount"]
        self.used_numbers.add((tx["from"], tx["number"]))
        return "ACCEPTED"

You should see: nothing. python3 ledger.py should return to the prompt in silence.

If not: ModuleNotFoundError: No module named 'cryptography' means the library is missing — run pip install cryptography and read its last line if it fails.

2
Make one honest payment

Go: same folder.

Do: save this as pay.py and run python3 pay.py.

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

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

book = Ledger({"alice": 100, "bob": 0})
tx = {"from": "alice", "to": "bob", "amount": 30, "number": 1}
sig = key.sign(f"{tx['from']}->{tx['to']}:{tx['amount']}#{tx['number']}".encode())

print("result :", book.apply(tx, sig, pub))
print("alice  :", book.balances["alice"])
print("bob    :", book.balances["bob"])

You should see: thirty coins move:

result : ACCEPTED
alice  : 70
bob    : 30

Nothing was “sent” anywhere. A payment is an instruction, signed by the payer, that every node applies to its own copy of the balances. That is the entire mechanism — and it is why the network needs no bank to move value between strangers.

If not: REJECTED: signature does not match means the message built for signing differs from the one apply rebuilds — the two f-strings must be identical, including the arrow and the hash sign.

3
Attack it four ways

Go: same folder. Replay a payment, overspend, and impersonate.

Do: save this as attack.py and run it.

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

def keypair(name):
    k = Ed25519PrivateKey.from_private_bytes(hashlib.sha256(name.encode()).digest())
    return k, k.public_key().public_bytes(serialization.Encoding.Raw,
                                          serialization.PublicFormat.Raw)

alice, alice_pub = keypair("alice")
mallory, mallory_pub = keypair("mallory")

book = Ledger({"alice": 100, "bob": 0, "carol": 0})

def send(sender_key, sender_pub, tx):
    sig = sender_key.sign(f"{tx['from']}->{tx['to']}:{tx['amount']}#{tx['number']}".encode())
    return book.apply(tx, sig, sender_pub)

print("1 alice sends 30 to bob      :",
      send(alice, alice_pub, {"from": "alice", "to": "bob", "amount": 30, "number": 1}))
print("2 the same transaction again :",
      send(alice, alice_pub, {"from": "alice", "to": "bob", "amount": 30, "number": 1}))
print("3 alice spends 90 she no longer has:",
      send(alice, alice_pub, {"from": "alice", "to": "carol", "amount": 90, "number": 2}))
print("4 mallory spends alice's coins:",
      send(mallory, mallory_pub, {"from": "alice", "to": "mallory", "amount": 50, "number": 3}))
print()
print("balances:", book.balances)

You should see: three attacks stopped and, if you read to the end, one that was not:

1 alice sends 30 to bob      : ACCEPTED
2 the same transaction again : REJECTED: this transaction number was already used
3 alice spends 90 she no longer has: REJECTED: not enough coins
4 mallory spends alice's coins: ACCEPTED

balances: {'alice': 20, 'bob': 30, 'carol': 0, 'mallory': 50}

Lines 2 and 3 are the two attacks people expect a cryptocurrency to stop, and the ledger stops them: the transaction number is why the same signed payment cannot be broadcast twice, and the balance check is the double-spend defence. Line 4 is the problem.

If not: if line 4 says REJECTED, you have already fixed the flaw — compare your apply with step 1, which deliberately contains it.

4
Work out why the theft succeeded

Go: no new file. Read apply in step 1 and answer before continuing: Mallory signed correctly, so the signature check passed — but signed what, and with whose key?

Do: look at the two places tx["from"] is used. The ledger takes the sender’s name from the transaction, and the key to check against from the same message. Mallory wrote "from": "alice", signed that with her own key, and handed over her own public key — so the signature genuinely matches, and the ledger never asks whether that key has anything to do with Alice.

You should see: the flaw stated in one sentence: the account name was claimed by the sender rather than derived from the key. Anyone can claim to be anyone.

This is not a contrived bug. It is exactly the mistake that appears in home-made authentication of every kind — trusting a name the caller supplied alongside a proof that is valid for a different name entirely.

If not: if the reasoning has not landed, add print(tx["from"]) and a print of public_key.hex()[:16] inside apply, then re-run attack.py. You will see line 4 arrive with Alice’s name and Mallory’s key.

5
Fix it the way real blockchains do

Go: same folder. The fix is to stop accepting a claimed name: derive the account from the public key, so the two cannot disagree.

Do: save this as ledger2.py.

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

def address_of(public_key: bytes) -> str:
    """The account name IS derived from the key. It cannot be chosen."""
    return hashlib.sha256(public_key).hexdigest()[:12]

class Ledger:
    def __init__(self, balances):
        self.balances = dict(balances)
        self.used_numbers = set()

    def apply(self, tx, signature, public_key):
        sender = address_of(public_key)              # not tx["from"] -- derived, not claimed
        message = f"{sender}->{tx['to']}:{tx['amount']}#{tx['number']}".encode()
        try:
            Ed25519PublicKey.from_public_bytes(public_key).verify(signature, message)
        except InvalidSignature:
            return "REJECTED: signature does not match"
        if (sender, tx["number"]) in self.used_numbers:
            return "REJECTED: this transaction number was already used"
        if self.balances.get(sender, 0) < tx["amount"]:
            return f"REJECTED: {sender} does not have {tx['amount']} coins"
        self.balances[sender] -= tx["amount"]
        self.balances[tx["to"]] = self.balances.get(tx["to"], 0) + tx["amount"]
        self.used_numbers.add((sender, tx["number"]))
        return f"ACCEPTED (from {sender})"

Now save this second file as fixed.py and run python3 fixed.py:

import hashlib
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
from ledger2 import Ledger, address_of

def keypair(name):
    k = Ed25519PrivateKey.from_private_bytes(hashlib.sha256(name.encode()).digest())
    return k, k.public_key().public_bytes(serialization.Encoding.Raw,
                                          serialization.PublicFormat.Raw)

alice, alice_pub = keypair("alice")
mallory, mallory_pub = keypair("mallory")

book = Ledger({address_of(alice_pub): 100})
print("alice's address  :", address_of(alice_pub))
print("mallory's address:", address_of(mallory_pub))

def send(key, pub, tx):
    sender = address_of(pub)
    sig = key.sign(f"{sender}->{tx['to']}:{tx['amount']}#{tx['number']}".encode())
    return book.apply(tx, sig, pub)

print("alice sends 30 to bob   :", send(alice, alice_pub, {"to": "bob", "amount": 30, "number": 1}))
print("mallory spends alice's  :", send(mallory, mallory_pub, {"to": "mallory", "amount": 50, "number": 1}))

You should see: the theft refused, and refused for the right reason:

alice's address  : 1c0c490f1b55
mallory's address: 1972da52ab08
alice sends 30 to bob   : ACCEPTED (from 1c0c490f1b55)
mallory spends alice's  : REJECTED: 1972da52ab08 does not have 50 coins

Notice there is no longer a "from" field at all — there is nothing to lie about. Mallory can still sign anything she likes; she simply signs it as herself, because her key produces her address and no other. This is why a cryptocurrency address is a hash of a public key rather than a username you pick. Identity is not asserted and checked; it is computed.

If not: REJECTED: signature does not match on Alice’s own payment means the message built in send differs from the one rebuilt in apply — both must start with the derived sender, not with a name.

🎉
Check yourself before moving on

Without scrolling up: in the fixed ledger, what stops Mallory taking a payment Alice genuinely signed and broadcasting it a second time to drain her account? And what would break if the transaction number were removed? Answer: the used-numbers check — the pair of sender address and transaction number is recorded when a payment is applied, so the same signed instruction is accepted exactly once. Remove the number and every payment becomes infinitely replayable by anybody who saw it, because the signature stays valid forever; the thief would not need Alice’s key at all, only a copy of a message she already sent. That is why every real chain carries a nonce, a sequence number, or spent-output tracking, and why they are as essential as the signature itself.

Now do it without the page: add a fee to the transaction — a small amount subtracted from the sender and paid to a "miner" account — and make sure the balance check covers amount plus fee, not just the amount. Then try to send your entire balance and watch it fail. You have just found the reason a wallet leaves a little behind when you press “send max”.

Summary

  • Cryptocurrency uses public/private key cryptography instead of bank accounts
  • Transactions are digitally signed and broadcast to a network of nodes
  • Mining (PoW) and staking (PoS) are methods for validating transactions and creating new blocks
  • Transaction fees incentivize validators and prevent network spam
  • More confirmations make transactions progressively harder to reverse
🎉
You now understand how cryptocurrency works!

Next, explore the different types of cryptocurrencies and their unique security characteristics.