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:
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.
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)
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.
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.
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.
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.
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.
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.
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
Next, explore the different types of cryptocurrencies and their unique security characteristics.