Skip to content

Hashing & Fingerprints

Everything in a blockchain is glued together by hashes. A block points back to the previous block by its hash. A transaction is identified by the hash of its contents. Proof of Work is a search for a hash with a special shape. Merkle trees are hashes of hashes. So it makes sense that the very first module we write — the one every other module imports — is hashing.

If you want the conceptual grounding for why hash functions behave the way they do (deterministic, one-way, avalanche, collision-resistant), read the concept page on Hash Functions (SHA-256). Here we’ll turn that idea into a handful of small, sturdy Rust functions.

The whole module is short — under thirty lines — so we’ll walk through it in pieces and stop to explain each new Rust feature as it appears. Open src/hashing.rs and let’s build it top to bottom.

//! SHA-256 helpers. Bitcoin hashes almost everything twice ("double SHA-256"),
//! which we follow here. A `Hash` is just 32 raw bytes.
use sha2::{Digest, Sha256};

The //! lines are a module-level doc comment, describing the file as a whole — they’re the first thing someone reads to learn what this module is for.

The use line is an import. It brings two names from the sha2 crate (the dependency we listed in Cargo.toml) into scope so we can write Sha256 instead of sha2::Sha256 everywhere. The curly braces let us import several names from the same path at once. We’re pulling in two things:

  • Sha256 — the type that actually computes a SHA-256 hash.
  • Digest — a trait that Sha256 implements. A trait is a set of shared behaviors; here it provides the new, update, and finalize methods we’re about to use. In Rust, a trait’s methods are only callable if the trait is in scope, so we have to import Digest even though we never write its name again. Don’t worry if traits feel abstract right now — we’ll meet them properly when we start deriving behavior onto our own structs.
/// A 256-bit hash: 32 bytes.
pub type Hash = [u8; 32];

This single line is doing something lovely. type Hash = [u8; 32] creates a type alias — a new name for an existing type. The existing type is [u8; 32], which reads as “an array of exactly 32 values, each a u8.” A u8 is an unsigned 8-bit integer: one byte, holding 0–255. So [u8; 32] is 32 bytes — exactly 256 bits, which is why SHA-256 has that name.

A type alias doesn’t create a new type; Hash and [u8; 32] are completely interchangeable. What it buys us is readability. From now on, when a function takes or returns a Hash, the intent is obvious — far clearer than seeing [u8; 32] scattered everywhere and wondering “wait, is that a hash, a key, or something else?”

The pub keyword makes the alias public, so other modules (like block and transaction) can refer to Hash. The /// comment above it is a doc comment for this specific item.

/// The all-zero hash, used for the genesis block's "previous" pointer and for
/// the null coinbase input.
pub const ZERO_HASH: Hash = [0u8; 32];

const defines a constant — a value baked in at compile time that never changes. ZERO_HASH is a Hash (our alias) whose value is [0u8; 32], the array-repeat syntax meaning “32 copies of the byte 0.” Constants are conventionally written in SCREAMING_SNAKE_CASE.

Why do we need a hash of all zeros? Two reasons, both flagged in the comment. The very first block in a chain — the genesis block — has no block before it, so its “previous block hash” field points at ZERO_HASH as a placeholder. And the special coinbase transaction that mints new coins has no real input to spend, so its phantom input also points at ZERO_HASH. It’s our universal “there is nothing here” sentinel. We’ll use it for real in the chapters on the blockchain and mining real transactions.

/// One round of SHA-256.
pub fn sha256(data: &[u8]) -> Hash {
let mut hasher = Sha256::new();
hasher.update(data);
hasher.finalize().into()
}

Here’s our first real function. pub fn declares a public function. Let’s read the signature carefully, because it introduces an important Rust idea.

The parameter is data: &[u8]. We saw [u8; 32] was a 32-byte array; [u8] is a slice — a view into some bytes without a fixed length. The & makes it a borrowed reference: the function gets to look at the bytes without taking ownership of them, so the caller keeps their data afterward.

The body has three steps:

  1. let mut hasher = Sha256::new(); creates a fresh hasher. let binds a variable; mut makes it mutable, because in the next line we’re going to change it. (Rust variables are immutable by default — you have to opt in to mutation with mut. This makes accidental changes a compile error rather than a silent bug.)
  2. hasher.update(data); feeds our bytes into the hasher. (You could call update several times to hash a stream of data piece by piece; we just need the one call.)
  3. hasher.finalize().into() produces the result. finalize() consumes the hasher and returns the 32-byte digest in sha2’s own array type. The .into() then converts that into our return type.

That .into() deserves a closer look. The function promises to return a Hash (our [u8; 32]). finalize() returns a GenericArray<u8, 32>sha2’s wrapper that holds the same 32 bytes. .into() is the conversion method that turns one type into another when Rust knows how. Because the return type of the function is Hash, Rust can figure out which conversion we want and turns the digest into a plain [u8; 32] for us. Notice there’s no semicolon on that last line: an expression without a trailing semicolon is the function’s return value. We could have written return ...;, but the trailing-expression style is idiomatic Rust.

/// Double SHA-256: `SHA256(SHA256(data))`. This is what Bitcoin uses for block
/// hashes, txids, and Merkle nodes.
pub fn sha256d(data: &[u8]) -> Hash {
sha256(&sha256(data))
}

This tiny function is one of the most important in the whole project. Bitcoin almost never hashes something just once — it hashes the result of one SHA-256 with a second SHA-256. That’s double SHA-256, written SHA256(SHA256(data)), and the concept page on Hash Functions explains the historical reasons (it defends against a class of “length-extension” attacks on the raw construction). For us, the rule is simple: block hashes, transaction IDs, and Merkle tree nodes all use sha256d.

Read the body inside-out:

  • sha256(data) hashes the input once, giving a Hash (a [u8; 32]).
  • &sha256(data) borrows that result. We need the & because sha256 wants a &[u8] slice, and an array reference &[u8; 32] coerces neatly into a &[u8] slice.
  • sha256(&...) hashes it a second time.

So the inner result flows straight into the outer call. We get to reuse sha256 rather than rewrite the hashing logic — small functions composing into bigger ones. Again, no semicolon, so the doubled hash is returned.

/// Render a hash as a lowercase hex string for display.
pub fn to_hex(hash: &Hash) -> String {
hex::encode(hash)
}

A raw [u8; 32] is 32 bytes of binary — fine for the computer, unreadable for us. When you saw 0000cd8509b9b185... in the overview, that was a hash rendered as hexadecimal: each byte shown as two hex digits (00ff). This helper does exactly that conversion for display.

The parameter is hash: &Hash — a borrowed reference to a Hash. We only need to read the bytes to format them, so we borrow rather than take ownership; the caller keeps their hash. The return type is String, Rust’s owned, growable, heap-allocated text type (as opposed to a &str string slice — we need an owned String here because we’re creating brand-new text).

The body calls hex::encode(hash) — the encode function from the hex crate we listed in Cargo.toml. It takes our bytes and produces the lowercase hex string. One call, one line, returned directly. This is the function that turns our internal hashes into the human-friendly fingerprints you’ll print all through the rest of the book.

That’s the entire hashing module: a Hash type alias, a ZERO_HASH constant, two hashing functions (sha256 and sha256d), and a to_hex formatter. Small, but it’s the bedrock — every block, transaction, Merkle node, and Proof-of-Work check from here on stands on these few lines. In the next chapter we’ll use sha256d and to_hex to give a block its identity.

Let’s hash something and see a real fingerprint. Add this test to the bottom of src/hashing.rs, then run it:

#[test]
fn hashes_hello() {
let h = sha256(b"hello");
println!("{}", to_hex(&h));
assert_eq!(h.len(), 32);
}

The b"hello" is a byte-string literal — it produces &[u8] (the bytes of “hello”) rather than a text &str, which is exactly what sha256 wants. Run it with output shown:

Terminal window
cargo test hashes_hello -- --nocapture

You should see 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 printed — the SHA-256 of “hello”. Hash it again and you’ll get the very same string: that’s determinism, the property every blockchain relies on.

  1. What does pub type Hash = [u8; 32]; create, and how is it different from defining a brand-new type?
  2. Why does sha256 take a slice &[u8] as its parameter instead of an array [u8; 32]?
  3. What is double SHA-256, and how does sha256d reuse sha256 to compute it?
  4. Why does the last line of sha256 end in .into() with no semicolon?
  5. Where will ZERO_HASH actually be used later, and why is an all-zero hash a sensible placeholder?
Show answers
  1. It creates a type alias — a second, more readable name for the existing type [u8; 32]. Unlike a brand-new type, the alias is completely interchangeable with [u8; 32] (no new distinct type is created); it just makes intent clear when a function takes or returns a Hash.
  2. A &[u8] slice is a borrowed view of bytes of any length, so one function can hash a 5-byte string, an 80-byte header, or a whole transaction. A fixed [u8; 32] array could only ever hash exactly 32 bytes; &Vec<u8>, arrays, and byte-string literals all coerce into &[u8] automatically.
  3. Double SHA-256 is SHA256(SHA256(data)) — hashing the result of one SHA-256 with a second — which Bitcoin uses for block hashes, txids, and Merkle nodes (it defends against length-extension attacks). sha256d reuses sha256 by calling it inside-out: sha256(&sha256(data)), borrowing the first result as a slice for the second call.
  4. .into() converts the digest from sha2’s GenericArray<u8, 32> into the function’s declared return type Hash ([u8; 32]); Rust infers which conversion from the return type. The missing semicolon makes it a trailing expression, which is the function’s return value — idiomatic Rust over return ...;.
  5. As the genesis block’s prev_block_hash (it has no parent) and the null coinbase input’s outpoint (it spends nothing). An all-zero hash is a sensible “there is nothing here” sentinel: no real hash collides with it in practice, so it cleanly marks “no previous block / no real input.”