Transactions & the UTXO Model
Here’s a question that trips up almost everyone new to Bitcoin: where is my balance stored? The surprising answer is nowhere. There is no row in a table that says “Alice: 90 coins.” Instead there are unspent transaction outputs — little locked chunks of value scattered across the chain — and your “balance” is just the sum of the ones you can unlock.
This is the UTXO model, and it’s one of the most important mental shifts in the whole book. In this
chapter we’ll build the transaction types that move value, and the UtxoSet that tracks what’s
spendable right now. Along the way you’ll meet Rust’s structs, enums-by-convention, Vec, HashMap,
tuple keys, Option, and a fistful of iterator methods.
Pointing at money: OutPoint
Section titled “Pointing at money: OutPoint”Every input must say which previous output it’s spending. An output is identified by two things: the
transaction it appeared in (txid) and its position in that transaction’s output list (vout, short
for “vector out”). That pair is an OutPoint. From src/transaction.rs:
/// The vout value a coinbase input uses (it points at no real output).pub const COINBASE_VOUT: u32 = u32::MAX;
/// A reference to a specific previous output: which transaction, which index.#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]pub struct OutPoint { pub txid: Hash, pub vout: u32,}
impl OutPoint { /// The null outpoint used by a coinbase input (it creates coins from nothing). pub fn null() -> Self { OutPoint { txid: ZERO_HASH, vout: COINBASE_VOUT } }
pub fn is_null(&self) -> bool { self.txid == ZERO_HASH && self.vout == COINBASE_VOUT }}A struct bundles named fields into one type — here a 32-byte txid and a u32 index. The
#[derive(...)] line asks the compiler to auto-generate trait implementations: Clone (make copies),
Debug (print for debugging), Serialize/Deserialize (save and load — we’ll use these later), and
crucially PartialEq, Eq so we can compare two outpoints with ==. We’ll need equality in a moment.
There’s one special case: the coinbase input — the one that mints brand-new coins in a mined block
— doesn’t spend anything real, so it uses a null outpoint: an all-zero txid and a vout of
u32::MAX (the largest possible u32, used here as a sentinel that means “no output”). null() builds
one and is_null() recognizes one.
Inputs and outputs
Section titled “Inputs and outputs”/// An input: which output it spends, plus the proof it may be spent/// (a signature and the public key that hashes to the output's recipient).#[derive(Clone, Debug, Serialize, Deserialize)]pub struct TxInput { pub previous_output: OutPoint, /// Compact ECDSA signature over the transaction's sighash. For a coinbase /// input this field is reused to carry arbitrary data (extra-nonce + height). pub signature: Vec<u8>, /// The compressed public key whose hash must equal the spent output's recipient. pub pubkey: Vec<u8>,}
/// An output: an amount (in the smallest unit) locked to a recipient./// `recipient` is `SHA256(pubkey)` — our simplified "address".#[derive(Clone, Debug, Serialize, Deserialize)]pub struct TxOutput { pub value: u64, pub recipient: Hash,}A TxOutput is the unit of money: a value (a u64 count of the smallest unit) locked to a
recipient. The recipient is SHA256(pubkey) — our stand-in for an address. To spend that output
later, you must prove you own the matching key.
A TxInput does the spending. It names the previous_output it consumes, and carries the proof:
a signature and the pubkey whose hash must equal the spent output’s recipient. Both proofs are
Vec<u8> — a Vec is Rust’s growable array, and Vec<u8> is “a list of bytes” of whatever length the
signature or key happens to be. (We’ll generate real signatures in
Keys, Signatures & Addresses; for now, just note where
they live.)
The transaction itself
Section titled “The transaction itself”/// A transaction is a list of inputs and a list of outputs.#[derive(Clone, Debug, Serialize, Deserialize)]pub struct Transaction { pub inputs: Vec<TxInput>, pub outputs: Vec<TxOutput>,}
impl Transaction { /// A coinbase has exactly one input, and that input is null. pub fn is_coinbase(&self) -> bool { self.inputs.len() == 1 && self.inputs[0].previous_output.is_null() }A Transaction is exactly that simple: a Vec of inputs and a Vec of outputs. It says “I’m
consuming these existing outputs and creating these new ones.” (The difference between inputs consumed
and outputs created, if any, becomes the miner’s fee — but that’s a later chapter.)
is_coinbase() recognizes the money-minting transaction: exactly one input, and that one input’s
previous_output is null. self.inputs.len() asks the Vec for its length; && is logical AND, and
self.inputs[0] indexes the first element. Coinbase transactions get special treatment in validation
because they’re allowed to create value from nothing — every other transaction must spend real outputs.
Two hashes: txid vs sighash
Section titled “Two hashes: txid vs sighash”This next part is subtle and important. A transaction needs an id (the txid, so other inputs can
point at its outputs), and it needs a message to sign (the sighash, the thing each signature
actually authorizes). They’re both double-SHA-256 of the transaction’s bytes — but they encode the
transaction differently, and one shared method handles both:
/// Canonical byte encoding used for hashing. /// /// When `include_witness` is false, every input's `signature` and `pubkey` /// are blanked. That blanked form is the **sighash** — what each signature /// signs — which keeps signing non-circular. The full form (witness /// included) is hashed to produce the **txid**. fn encode(&self, include_witness: bool) -> Vec<u8> { let mut buf = Vec::new(); buf.extend_from_slice(&(self.inputs.len() as u32).to_le_bytes()); for input in &self.inputs { buf.extend_from_slice(&input.previous_output.txid); buf.extend_from_slice(&input.previous_output.vout.to_le_bytes()); if include_witness { buf.extend_from_slice(&(input.signature.len() as u32).to_le_bytes()); buf.extend_from_slice(&input.signature); buf.extend_from_slice(&(input.pubkey.len() as u32).to_le_bytes()); buf.extend_from_slice(&input.pubkey); } } buf.extend_from_slice(&(self.outputs.len() as u32).to_le_bytes()); for output in &self.outputs { buf.extend_from_slice(&output.value.to_le_bytes()); buf.extend_from_slice(&output.recipient); } buf }
/// The transaction id: double-SHA-256 of the full encoding. pub fn txid(&self) -> Hash { sha256d(&self.encode(true)) }
/// The message each input signs: double-SHA-256 with signatures blanked. pub fn sighash(&self) -> Hash { sha256d(&self.encode(false)) }}encode serializes the transaction into a Vec<u8> we can hash. It writes a length prefix, then each
input’s outpoint, then each output’s value and recipient. extend_from_slice appends bytes to the
buffer; to_le_bytes turns an integer into its little-endian byte form so the encoding is exactly
reproducible on any machine.
The pivot is the include_witness flag:
txid()callsencode(true)— the full form, signatures and pubkeys included. This is the transaction’s permanent identity.sighash()callsencode(false)— the blanked form, where each input’ssignatureandpubkeyare omitted entirely.
Why blank them? Because of a chicken-and-egg problem. A signature has to sign the transaction, but the signature is part of the transaction. If the signature signed itself, you could never compute it — the input would change the moment you added the signature. Blanking the witness before signing breaks that loop: every input signs a stable, signature-free version of the transaction.
The UTXO set: the chain’s current state
Section titled “The UTXO set: the chain’s current state”The blockchain is the full history of every transaction. But to answer “can this input be spent?” or
“what’s Alice’s balance?” you don’t want to replay all of history every time. You want a fast index of
what’s unspent right now. That’s the UTXO set. From src/utxo.rs:
use std::collections::HashMap;
use crate::hashing::Hash;use crate::transaction::{OutPoint, Transaction, TxOutput};
#[derive(Clone, Debug, Default)]pub struct UtxoSet { pub utxos: HashMap<(Hash, u32), TxOutput>,}
impl UtxoSet { pub fn new() -> Self { Self::default() }
/// Look up an unspent output by outpoint. pub fn get(&self, outpoint: &OutPoint) -> Option<&TxOutput> { self.utxos.get(&(outpoint.txid, outpoint.vout)) }The heart of it is a HashMap<(Hash, u32), TxOutput>. A HashMap is a key-value dictionary; here the
key is a tuple (Hash, u32) — exactly the (txid, vout) pair that identifies an output — and the
value is the TxOutput itself. Using a tuple as a key works because both Hash and u32 can be
hashed and compared. If an entry exists in this map, that output is unspent; if it’s gone, it’s spent.
get looks one up. It returns Option<&TxOutput> — an Option is Rust’s way of saying “maybe a
value, maybe nothing.” You get Some(output) if the outpoint is unspent, or None if it isn’t (spent
or never existed). Rust forces you to handle the None case, which is how it prevents the
null-pointer bugs that plague other languages.
Applying a transaction
Section titled “Applying a transaction”When a new transaction is confirmed, the set must change: the outputs it spends disappear, and the outputs it creates appear.
/// Apply a transaction: remove the outputs it spends, add the ones it creates. pub fn add_transaction(&mut self, txid: Hash, tx: &Transaction) { for input in &tx.inputs { self.utxos .remove(&(input.previous_output.txid, input.previous_output.vout)); } for (index, output) in tx.outputs.iter().enumerate() { self.utxos.insert((txid, index as u32), output.clone()); } }Two loops. The first walks the inputs and removes each spent outpoint from the map — those coins are
now consumed and can never be spent again. The second walks the outputs and inserts each one under
its new key.
That second loop uses .iter().enumerate(), a hugely common Rust pattern. iter() gives an iterator
over the outputs, and enumerate() pairs each item with its index, yielding (index, output). We need
the index because it is the vout — the position in the output list. We cast it with index as u32
and store the output under (txid, vout). (output.clone() makes an owned copy because the map needs
to own its values, and we only borrowed tx.)
Computing a balance
Section titled “Computing a balance”Finally, the payoff — turning scattered UTXOs into a single number:
/// The spendable balance of an address: the sum of UTXOs locked to it. pub fn balance(&self, address: &Hash) -> u64 { self.utxos .values() .filter(|o| &o.recipient == address) .map(|o| o.value) .sum() }This is the UTXO model in one expression, read top to bottom as a pipeline:
.values()— iterate over everyTxOutputin the set (we don’t care about the keys here)..filter(|o| &o.recipient == address)— keep only the outputs locked to the address we’re asking about. The|o| ...is a closure, an inline function; it’s called once per output and returns abool..map(|o| o.value)— transform each surviving output into just itsvalue, giving a stream ofu64s..sum()— add them all up into oneu64.
There is no stored balance anywhere. A balance is computed on demand by summing the unspent outputs you own. That’s the entire idea: coins are discrete outputs, and your wealth is whichever ones you can still unlock.
Try it
Section titled “Try it”Run the demo and watch a payment move value between UTXO owners:
cd rust/blockchain-from-scratchcargo run -- demoAfter block 2 (Alice → Bob), the output shows:
Alice: 90 coins Bob: 10 coinsBob’s 10 coins isn’t a number someone edited — it’s the sum of the UTXOs now locked to Bob’s address,
exactly what balance() computes. Alice’s 90 is her change output plus her other unspent coins. No
accounts were harmed in the making of this payment.
Check your understanding
Section titled “Check your understanding”- There’s no field anywhere that stores “Alice: 90 coins.” Where does that number actually come from when the demo prints it?
txid()andsighash()both double-SHA-256 the transaction. What single argument toencodemakes them differ, and why must the signed form blank the witness?- Why is the
UtxoSetkeyed by a tuple(Hash, u32)rather than just atxid? - In
add_transaction, why does the output loop needenumerate()— what is the index used for? - Blanking the witness in the signed message solves one problem; leaving the witness out of the txid (as SegWit does) solves another. Which problem does our chain solve, and which does it still have? (See Transaction Malleability.)
Show answers
- From summing the UTXOs locked to Alice’s address —
balance()filters the UTXO set for outputs whoserecipientis Alice and adds up their values. There’s no stored balance field anywhere; the number is computed on demand from her unspent outputs. - The
include_witnessflag.txid()callsencode(true)(signatures included);sighash()callsencode(false)(signatures and pubkeys blanked). The signed form must blank the witness to break a chicken-and-egg loop: the signature is part of the transaction, so signing a form that includes it would be circular — blanking gives every input a stable, signature-free message to sign. - Because an output is identified by both its transaction (
txid) and its position in that transaction’s output list (vout) — one transaction can have many outputs, so atxidalone is ambiguous. The(Hash, u32)tuple(txid, vout)uniquely names a single output. enumerate()pairs each output with its index, and that index is thevout— the output’s position in the list — which becomes part of the(txid, vout)key the UTXO is inserted under. Without the index there’d be no way to address each new output.- Blanking the signed message (our
sighash) solves circular signing — a signature can’t sign bytes that contain itself, so every input signs a stable, signature-free form. It does not stop malleability: ourtxid()still hashes the signatures, so a re-encoded signature would change the txid, exactly like pre-SegWit Bitcoin. SegWit fixed that separately by leaving the witness out of the txid computation. See Transaction Malleability.