Skip to content

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.

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.

/// 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.)

/// 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.

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() calls encode(true) — the full form, signatures and pubkeys included. This is the transaction’s permanent identity.
  • sighash() calls encode(false) — the blanked form, where each input’s signature and pubkey are 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 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.

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.)

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 every TxOutput in 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 a bool.
  • .map(|o| o.value) — transform each surviving output into just its value, giving a stream of u64s.
  • .sum() — add them all up into one u64.

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.

Run the demo and watch a payment move value between UTXO owners:

Terminal window
cd rust/blockchain-from-scratch
cargo run -- demo

After block 2 (Alice → Bob), the output shows:

Alice: 90 coins
Bob: 10 coins

Bob’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.

  1. There’s no field anywhere that stores “Alice: 90 coins.” Where does that number actually come from when the demo prints it?
  2. txid() and sighash() both double-SHA-256 the transaction. What single argument to encode makes them differ, and why must the signed form blank the witness?
  3. Why is the UtxoSet keyed by a tuple (Hash, u32) rather than just a txid?
  4. In add_transaction, why does the output loop need enumerate() — what is the index used for?
  5. 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
  1. From summing the UTXOs locked to Alice’s addressbalance() filters the UTXO set for outputs whose recipient is Alice and adds up their values. There’s no stored balance field anywhere; the number is computed on demand from her unspent outputs.
  2. The include_witness flag. txid() calls encode(true) (signatures included); sighash() calls encode(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.
  3. 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 a txid alone is ambiguous. The (Hash, u32) tuple (txid, vout) uniquely names a single output.
  4. enumerate() pairs each output with its index, and that index is the vout — 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.
  5. 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: our txid() 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.