Full Chain Validation
Last chapter we built blocks. This chapter we check them — and this is where the whole point of a blockchain finally clicks. A blockchain isn’t trustless because of cryptography alone; it’s trustless because every node independently re-checks every rule and rejects anything that breaks them. A miner can propose whatever block it likes, but if that block tries to forge a signature, spend a coin twice, or print money it isn’t owed, every honest node throws it away. The rules are the trust.
We’ll build the two validation functions in src/blockchain.rs: validate_tx, which decides whether a
single transaction is honest, and validate_block, which checks a whole block. Along the way you’ll
learn Result<_, String>, the ? operator, ok_or_else, and HashSet — Rust’s vocabulary for
“this might fail, and here’s exactly why.”
Validating one transaction: validate_tx
Section titled “Validating one transaction: validate_tx”Here is the function that catches forgers and double-spenders. It returns Result<u64, String> — the
fee on success, or an error string explaining the rejection. From src/blockchain.rs:
/// Validate a single (non-coinbase) transaction against a UTXO view./// Returns the fee (inputs − outputs) on success.pub fn validate_tx(tx: &Transaction, view: &UtxoSet) -> Result<u64, String> { if tx.inputs.is_empty() || tx.outputs.is_empty() { return Err("transaction must have at least one input and output".into()); } let sighash = tx.sighash(); let mut input_sum: u64 = 0; let mut seen = std::collections::HashSet::new(); for input in &tx.inputs { let op = &input.previous_output; if !seen.insert((op.txid, op.vout)) { return Err("duplicate input within transaction".into()); }The return type Result<u64, String> is Rust’s standard “success-or-failure” shape: it’s either
Ok(fee) or Err(message). We use it everywhere a check can fail. The first guard rejects empty
transactions — no inputs or no outputs makes no sense. (.into() converts the &str literal into the
owned String the error type wants.)
Then we compute the sighash once — the message every signature must have signed — and set up two
accumulators: input_sum for the total value being spent, and seen, a HashSet. A HashSet is a
collection of unique values with fast membership tests, and insert returns false if the value was
already present. So if !seen.insert((op.txid, op.vout)) reads as “if this outpoint was already in the
set, it’s a duplicate — reject.” This catches a transaction trying to spend the same output twice
within itself.
let prev = view .get(op) .ok_or_else(|| "input spends a non-existent or already-spent output".to_string())?; if wallet::address_of(&input.pubkey) != prev.recipient { return Err("public key does not match the output's recipient".into()); } if !verify(&sighash, &input.signature, &input.pubkey) { return Err("invalid signature".into()); } input_sum += prev.value; }These three checks are the heart of trustlessness. First, the UTXO must exist: view.get(op)
returns Option<&TxOutput> — Some if the output is unspent, None if it’s gone or never existed.
ok_or_else converts that Option into a Result: if it’s Some, we get the value; if it’s None,
we get the Err with our message. The trailing ? is the magic — it says “unwrap the Ok, or
return the Err from this whole function immediately.” Two characters replace an entire match. If you
try to spend a coin that doesn’t exist (or that someone already spent), validation stops right here.
Second, the pubkey must hash to the recipient: wallet::address_of(&input.pubkey) is
SHA256(pubkey), our address. If it doesn’t equal the output’s recipient, you’re presenting the wrong
key — you’re trying to spend someone else’s coin. Rejected.
Third, the signature must verify: verify(&sighash, &input.signature, &input.pubkey) checks the
secp256k1 signature against the sighash and the pubkey. A forged or tampered signature fails here. Only
if all three pass do we add this output’s value to input_sum.
let output_sum: u64 = tx.outputs.iter().map(|o| o.value).sum(); if output_sum > input_sum { return Err("outputs exceed inputs".into()); } Ok(input_sum - output_sum)}Finally, the conservation rule: you can’t spend more than you have. We sum the outputs with the
familiar .iter().map(|o| o.value).sum() pipeline, and if output_sum > input_sum the transaction is
trying to create money — rejected. Otherwise the difference input_sum - output_sum is the fee,
which we return as Ok. (That’s the fee mine_block collected last chapter. Now you see where the
number comes from.)
Validating a whole block: validate_block
Section titled “Validating a whole block: validate_block”A block is more than its transactions — it has a header that must link the chain, prove work, and commit
to its contents honestly. validate_block checks all of it:
/// Validate a block against the current tip and consensus rules. pub fn validate_block(&self, block: &Block) -> Result<(), String> { if block.header.prev_block_hash != self.tip_hash() { return Err("prev_block_hash does not extend the current tip".into()); } if block.header.bits < DIFFICULTY_BITS { return Err("block difficulty is below the minimum".into()); } if !meets_target(&block.hash(), block.header.bits) { return Err("block hash does not meet the difficulty target".into()); } if block.transactions.is_empty() { return Err("block has no transactions".into()); } if block.header.merkle_root != block.compute_merkle_root() { return Err("merkle root does not match transactions".into()); }Note the return type: Result<(), String>. There’s no useful value on success — just “it’s valid” —
so the Ok carries the unit type (). The header checks come first because they’re cheap:
- prev-hash links the tip: the block’s
prev_block_hashmust equal our currenttip_hash(), or it doesn’t extend our chain. This is the link that makes a chain a chain. - difficulty floor:
bitscan’t drop belowDIFFICULTY_BITS, so nobody can lower the bar to make mining trivial. - Proof of Work:
meets_target(&block.hash(), block.header.bits)re-checks that the hash actually has enough leading zeros. The miner did the work; we verify it for free. - non-empty: a block must contain at least the coinbase.
- Merkle root matches: the header’s committed
merkle_rootmust equal the root recomputed from the actual transactions. If anyone swapped a transaction after mining, this mismatch catches it.
if !block.transactions[0].is_coinbase() { return Err("the first transaction must be the coinbase".into()); } for tx in &block.transactions[1..] { if tx.is_coinbase() { return Err("only the first transaction may be a coinbase".into()); } }Exactly one coinbase, and it must be first. We check transactions[0] is a coinbase, then loop
over &block.transactions[1..] — a slice of everything after the first — and reject any other
coinbase. This stops a miner from sneaking in extra money-minting transactions to multiply the reward.
// Validate every spend against a working copy of the UTXO set so that // intra-block spends work and double-spends are caught. let mut view = self.utxos.clone(); let height = self.blocks.len() as u64; let mut total_fees = 0u64; for tx in &block.transactions[1..] { let fee = validate_tx(tx, &view)?; total_fees += fee; view.add_transaction(tx.txid(), tx); }Now we validate every real transaction. Just like mine_block, we clone the UTXO set into a working
view and apply each transaction as we accept it. This does two things at once: it lets a later
transaction spend an output an earlier one in the same block created (intra-block spends), and it catches
double-spends — if two transactions in the block try to spend the same UTXO, the second won’t find
it in the view and validate_tx returns Err. The ? on validate_tx(tx, &view)? propagates that
error straight out of validate_block. Every accepted fee is summed into total_fees.
let coinbase_out: u64 = block.transactions[0].outputs.iter().map(|o| o.value).sum(); let allowed = subsidy(height) + total_fees; if coinbase_out > allowed { return Err(format!( "coinbase pays {coinbase_out} but only {allowed} is allowed" )); } Ok(()) }The last and most important check: the coinbase may not overpay. We sum what the coinbase actually
pays out (coinbase_out) and compare it to what’s allowed — subsidy(height) + total_fees. If the
miner tried to mint even one extra coin, coinbase_out > allowed and the block is rejected. This is the
rule that enforces the money supply. Without it, a miner could simply pay themselves a billion coins;
with it, the entire network refuses such a block. If everything passes, we return Ok(()).
Try it
Section titled “Try it”The crate’s tests prove these rules actually reject cheating. Run them and read the four that exercise validation:
cd rust/blockchain-from-scratchcargo testLook for the tests that build a valid block and then tamper with it — bumping the coinbase payout,
spending the same UTXO twice, and swapping in a forged signature. Each one asserts that validate_block
or validate_tx returns Err. Reading these is the fastest way to see the rules bite: a tampered
coinbase, a double-spend, and a forged signature are all caught, and a legitimate block still passes.
Check your understanding
Section titled “Check your understanding”validate_txreturnsResult<u64, String>. What does theu64represent on success, and how is it used by the code that mines a block?- The
?operator appears afterview.get(op)...(viaok_or_else) and aftervalidate_tx(tx, &view). In one sentence, what does?do in both places? - Why does
validate_blockclone the UTXO set into a workingviewbefore checking transactions, and how does that single clone catch a double-spend within the block? - What stops a miner from paying itself more than it’s owed, and what exactly is the “allowed” amount?
- The block checks the header’s
merkle_rootagainst a freshly recomputed root. What attack does that single comparison defeat?
Show answers
- The
u64is the fee —input_sum − output_sum, the value the inputs exceed the outputs by.mine_blockaccumulates each transaction’s returned fee intototal_feesand pays it to the miner via the coinbase (subsidy + fees). - The
?operator unwraps theOkvalue and continues, or — if it’s anErr— immediately returns that error from the whole function. So a missing/already-spent UTXO or a failingvalidate_txshort-circuits validation right there with the explanatory error. - So it can apply each accepted transaction to a sandbox copy without touching the live UTXO set, which lets a later transaction spend an output an earlier one in the same block created. That single clone catches a double-spend within the block: once the first transaction consumes a UTXO it’s removed from the
view, so a second transaction trying to spend it won’t find it andvalidate_txreturnsErr. - The check
coinbase_out > allowedrejects any block whose coinbase pays out more thanallowed = subsidy(height) + total_fees— the freshly-minted block reward plus the fees actually collected from the block’s transactions. A miner that tries to mint even one extra coin has its block refused by the entire network; this is the rule that enforces the money supply. - It defeats a post-mining transaction swap/tamper: if anyone replaced or altered a transaction after the block was mined, the recomputed Merkle root would no longer equal the header’s committed
merkle_root, so the block is rejected. The header’s commitment is bound to the exact transaction set.