Skip to content

Chapter 1: Core Primitives

Every blockchain rests on a foundation of cryptographic primitives. In this chapter, we’ll build the core crate — the bedrock upon which everything else depends.

By the end of this chapter, you’ll have implemented:

ModulePurpose
hash.rsBlake3 hashing utilities
crypto.rsEd25519 signatures and addresses
account.rsAccount state representation
transaction.rsTransaction types and signing
block.rsBlock and header structures
merkle.rsMerkle tree for transaction proofs

Hashing is the heartbeat of a blockchain. We use it for:

  • Block identification
  • Transaction fingerprinting
  • Merkle tree construction
  • Address derivation
Official BLAKE3 Repository →
AlgorithmSpeedSecurityOutput
SHA-256ModerateProven32 bytes
Keccak-256ModerateProven32 bytes
Blake3Very FastModern32 bytes

Blake3 is extremely fast (especially on modern CPUs with SIMD) while maintaining strong security properties. It’s based on the BLAKE2 design but optimized for parallelism and performance.

We wrap the raw 32-byte array in a newtype for better ergonomics:

/// A 256-bit hash value.
pub type H256 = [u8; 32];
/// A wrapper type for H256 with Display and Debug formatting.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub struct Hash(pub H256);
impl Hash {
/// The zero hash (all zeros).
pub const ZERO: Self = Self([0u8; 32]);
/// Convert to a hex string.
pub fn to_hex(&self) -> String {
hex::encode(self.0)
}
/// Parse from a hex string.
pub fn from_hex(s: &str) -> Result<Self, hex::FromHexError> {
let bytes = hex::decode(s)?;
if bytes.len() != 32 {
return Err(hex::FromHexError::InvalidStringLength);
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Ok(Self(arr))
}
}
/// Hash arbitrary data using Blake3.
pub fn hash(data: &[u8]) -> Hash {
Hash(blake3::hash(data).into())
}
/// Hash multiple pieces of data by concatenating them.
pub fn hash_concat(parts: &[&[u8]]) -> Hash {
let mut hasher = blake3::Hasher::new();
for part in parts {
hasher.update(part);
}
Hash(hasher.finalize().into())
}

Digital signatures let us prove ownership without revealing secrets. We use Ed25519 for its speed and compact 64-byte signatures.

Ethereum uses secp256k1 (the same curve as Bitcoin). So why does Minichain choose Ed25519?

FeatureEd25519secp256k1 (Ethereum)
Signature Size64 bytes64-65 bytes
Public Key Size32 bytes33-65 bytes (compressed/uncompressed)
SpeedVery fast (~5-10x faster signing)Moderate
Public Key Recovery❌ No (needs explicit pubkey)✅ Yes (from signature + message)
Security Level~128 bits~128 bits
StandardizationModern (2011)Older (Bitcoin-era)
Use CasesSSH, TLS, SignalBitcoin, Ethereum, most blockchains

The biggest difference is public key recovery:

secp256k1 (Ethereum):

// Ethereum can recover the signer's public key from just the signature
let signature = sign(message, private_key);
let public_key = recover_pubkey(signature, message); // ✓ Possible
let address = hash(public_key); // Derive address from recovered pubkey

Ed25519 (Minichain):

// Ed25519 requires the public key to verify
let signature = sign(message, private_key);
// Cannot recover public key from signature alone!
// Must store/provide public key explicitly for verification
verify(message, signature, public_key); // ✓ Requires pubkey

Ethereum’s Advantage (secp256k1):

  • Transactions only need signature (not public key) → smaller
  • Block headers don’t need author field → simpler
  • Address is directly derived from signature

Minichain’s Choice (Ed25519):

  • Faster signing/verification (important for throughput)
  • Simpler implementation (no complex recovery algorithm)
  • Smaller public keys (32 bytes vs 33-65 bytes)
  • Modern cryptography (designed with side-channel resistance)

Architectural Consequence: Since Ed25519 can’t recover public keys, Minichain blocks must include an explicit author field (see section on author field below). This is a small overhead (~20 bytes per block) in exchange for faster cryptography.

For a learning blockchain, the advantages are clear:

PriorityWhy Ed25519 Wins
SimplicityNo complex recovery algorithm to implement
Performance5-10x faster signing (better for testing/development)
Modern best practicesRecommended by cryptographers for new systems
Small overheadExplicit author field is only ~20 bytes per block

Minichain uses battle-tested Rust crates for cryptography:

For Ed25519 signatures:

  • Crate: ed25519-dalek version 2.1
  • What it provides: Fast, safe Ed25519 signing and verification
  • Why this crate: Industry standard, used by Signal, 1Password, and many Rust projects
  • Features: Side-channel resistant, no unsafe code, thoroughly audited

For hashing (Blake3):

  • Crate: blake3 version 1.5
  • What it provides: Cryptographic hash function (addresses, block hashes, Merkle trees)
  • Why this crate: Fastest secure hash, official reference implementation
  • Performance: ~10x faster than SHA-256, parallelizable
// From crates/core/Cargo.toml
[dependencies]
blake3 = "1.5"
ed25519-dalek = { version = "2.1", features = ["rand_core"] }

Both crates are maintained by respected cryptography teams and widely used in production systems.

An address is derived from a public key:

Address Derivation

pub type AddressBytes = [u8; 20];
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub struct Address(pub AddressBytes);
impl Address {
pub const ZERO: Self = Self([0u8; 20]);
pub fn to_hex(&self) -> String {
format!("0x{}", hex::encode(self.0))
}
pub fn from_hex(s: &str) -> Result<Self, CryptoError> {
let s = s.strip_prefix("0x").unwrap_or(s);
let bytes = hex::decode(s).map_err(|_| CryptoError::InvalidAddress)?;
// ... validation
}
}

A Keypair bundles signing and verification capabilities:

pub struct Keypair {
signing_key: SigningKey,
pub public_key: PublicKey,
}
impl Keypair {
/// Generate a new random keypair.
pub fn generate() -> Self {
let signing_key = SigningKey::generate(&mut OsRng);
let verifying_key = signing_key.verifying_key();
Self {
signing_key,
public_key: PublicKey(verifying_key),
}
}
/// Get the address derived from the public key.
pub fn address(&self) -> Address {
self.public_key.to_address()
}
/// Sign a message.
pub fn sign(&self, message: &[u8]) -> Signature {
let sig = self.signing_key.sign(message);
Signature(sig.to_bytes())
}
}
// Generate a new identity
let keypair = Keypair::generate();
println!("Address: {}", keypair.address());
// Sign a message
let message = b"hello blockchain";
let signature = keypair.sign(message);
// Verify the signature
assert!(keypair.public_key.verify(message, &signature).is_ok());

An account represents an entity on the blockchain — either a user or a smart contract.

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Account {
/// Transaction count / sequence number.
pub nonce: u64,
/// Account balance in the Mini Coin.
pub balance: u64,
/// Hash of the contract bytecode (None for EOAs).
pub code_hash: Option<Hash>,
/// Root hash of the account's storage trie.
pub storage_root: Hash,
}
// Externally Owned Account - controlled by a private key
let user = Account::new_user(1000);
assert!(user.is_eoa());
assert_eq!(user.code_hash, None);

Core analogy (one you can say in one breath): An EOA is like a personal bank account you control directly, while a contract account is like an automated corporate account that can only move money according to pre-written rules.


🧑‍💼 EOA (Externally Owned Account)

Section titled “🧑‍💼 EOA (Externally Owned Account)”

Analogy: Your personal savings/checking account

  • You control it with your signature → like signing a cheque or authorizing a transaction with your PIN
  • Money moves only when you explicitly approve it
  • No internal logic — it just sends and receives money

Blockchain mapping:

  • Controlled by a private key
  • Can initiate transactions
  • No code, just balance + nonce

Analogy: A corporate bank account with standing instructions (or an automated escrow)

  • No human “signs” transactions directly
  • Money moves only when predefined rules are satisfied (e.g., “release funds after both parties approve”)
  • Cannot act on its own — it reacts when someone triggers it

Blockchain mapping:

  • Controlled by code
  • Cannot initiate transactions by itself
  • Executes logic when called

One subtle but important point: A contract account never “decides” to send money — it only executes instructions when someone calls it.

Bank analogy: A corporate account won’t randomly transfer funds. Someone submits a request, and the bank checks the rules before executing it.

The separation: Blockchain separates identity (EOA) from automation (contracts) — like separating a person from the company policies they operate under.

The nonce field is critical for security.

Core analogy: A nonce is like a cheque number — the bank will only process each cheque number once, so you can’t reuse an old signed cheque to withdraw money again.


  • Every cheque has a unique, increasing number
  • The bank records the last used number
  • Reusing an old cheque = rejected
  • Skipping ahead is allowed, but going backward isn’t
  • Every account has a nonce (transaction counter)
  • Each transaction must use the next nonce
  • Replaying an old signed transaction = rejected
  • Ensures transactions execute once and in order

impl Account {
pub fn increment_nonce(&mut self) {
self.nonce = self.nonce.saturating_add(1);
}
}

Every transaction must include the sender’s current nonce. This prevents:

  • Replay attacks — can’t resubmit the same transaction
  • Transaction ordering — nonces must be sequential

A transaction is a signed request to change blockchain state.

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Transaction {
pub nonce: u64, // Sender's nonce
pub from: Address, // Sender
pub to: Option<Address>, // Recipient (None = deploy)
pub value: u64, // Amount to transfer
pub data: Vec<u8>, // Calldata or bytecode
pub gas_limit: u64, // Max gas
pub gas_price: u64, // Price per gas unit
pub signature: Signature, // Ed25519 signature
}

Understanding Gas: gas_limit and gas_price

Section titled “Understanding Gas: gas_limit and gas_price”

Core analogy: Gas is like a wire transfer fee — you set a maximum budget (gas_limit) and a rate you’re willing to pay (gas_price). The bank charges you only for the actual work done, up to your limit.


  • Fee cap: “I’ll pay up to $50 for this transfer”
  • Priority rate: Standard (5),Express(5), Express (15), Urgent ($30)
  • Actual charge: Bank charges based on complexity, but never exceeds your cap
  • If cap too low: Transfer is rejected (“insufficient fee budget”)
  • gas_limit: Maximum gas units you’re willing to consume
  • gas_price: How much you pay per gas unit (higher = faster inclusion)
  • Actual cost: gas_used × gas_price (refund unused gas)
  • If limit too low: Transaction fails (“out of gas”), but fees are still consumed

Why Two Numbers? Why Not Just a Single Fee?

Section titled “Why Two Numbers? Why Not Just a Single Fee?”

Question: Can’t we just say “I’ll pay $10 for this transaction” instead of gas_limit × gas_price?

The key insight: These two numbers measure different things.

FieldWhat It MeasuresWho Controls It
gas_limitComputation — how much workThe operation itself (deterministic)
gas_pricePriority — how fast you want itYou (market-driven)

🏦 Banking Analogy:

Think of it like shipping a package:

  • Weight/Size (gas_limit) = How big is your package? A letter vs a refrigerator need different resources.
  • Shipping Speed (gas_price) = Standard, Express, or Overnight? You choose based on urgency.

You can’t combine these into one number because:

  • A small package sent overnight (low gas, high price) costs differently than
  • A huge package sent standard (high gas, low price)

  1. Refunds work — You set gas_limit=100,000 but only use 21,000? You get 79,000 × gas_price back. A single fee = no refunds.

  2. Predictable estimation — Gas for a transfer is always ~21,000. Gas for a complex contract call might be 500,000. You can estimate this before choosing your price.

  3. Market flexibility — Network busy? Raise gas_price, not gas_limit. Your operation doesn’t need more computation, just faster inclusion.

  4. Prevents overpaying — Single fee = you’d always pay maximum. Two numbers = pay only for actual work done.


Wait — Isn’t Gas Fixed for Simple Transfers?

Section titled “Wait — Isn’t Gas Fixed for Simple Transfers?”

Yes! For EOA → EOA transfers (no contract code), gas is always 21,000. It’s deterministic:

Verify signature → Check balance → Transfer value → Increment nonce

Same steps every time = same gas every time.

So why have gas_limit at all?

Transaction TypeGas Usage
EOA → EOA transferFixed: 21,000
Contract deploymentVariable: depends on bytecode size
Contract callVariable: depends on code execution path

The Transaction struct handles all types. For simple transfers, gas_limit = 21,000 is just a formality. For contract interactions, it’s crucial — the same function might use 50,000 or 500,000 gas depending on inputs and state.


ProblemHow Gas Solves It
Infinite loops in contractsExecution stops when gas runs out
Spam transactionsEvery operation costs money
Resource fairnessUsers pay for computation they consume
Miner/validator incentivesGas fees reward block producers

FieldWhat It MeansBanking Equivalent
gas_limitMax gas you’ll spendFee budget cap
gas_priceCost per gas unitPriority/speed tier
gas_usedActual gas consumedActual fee charged
gas_limit × gas_priceMax possible costWorst-case fee

A blockchain supports three fundamental operations, all using the same Transaction struct.

Why Three Types? The Human-Machine Perspective

Section titled “Why Three Types? The Human-Machine Perspective”

Every blockchain transaction is initiated by a human (EOA). Machines (contracts) cannot act on their own — they only react when triggered. This gives us a natural categorization:

FromToTypeWhat Happens
HumanHumanTransferMoney moves, nothing else
HumanNew MachineDeployA new autonomous program is born
HumanExisting MachineCallThe machine wakes up and executes its code

Notice what’s missing: Machine → Anyone as an initiating transaction. Contracts can send funds or call other contracts, but only during execution of a human-initiated transaction. A contract sitting idle will never “decide” to do something.

  1. Transfer (Human → Human): The simplest case. No code runs. Just update two balances. This is like handing cash to a friend — no intermediary, no rules, just value moving.

  2. Deploy (Human → New Machine): You’re creating an autonomous agent that will live on the blockchain forever. It’s not sending money to someone — it’s bringing something into existence. That’s why to is None — there’s no recipient yet; you’re creating one.

  3. Call (Human → Existing Machine): You’re waking up a sleeping program. The machine reads your input (data), possibly accepts your payment (value), executes its logic, and may trigger a chain reaction (calling other machines, sending funds, updating storage).

Rather than three separate structs, we use one struct with different interpretations:

  • to = None → Deployment (no recipient — you’re creating one)
  • to = EOA → Transfer (recipient has no code)
  • to = Contract → Call (recipient has code, execute it)

This keeps the protocol simple while supporting all use cases:

// 1. Value Transfer
let transfer = Transaction::transfer(from, to, 1000, nonce, gas_price);
// 2. Contract Deployment (to = None)
let deploy = Transaction::deploy(from, bytecode, nonce, gas_limit, gas_price);
// 3. Contract Call
let call = Transaction::call(from, contract, calldata, value, nonce, gas_limit, gas_price);

Banking analogy: A simple bank transfer — move money from your account to someone else’s.

FieldValueMeaning
toRecipient addressWho receives the money
valueAmountHow much to send
dataEmpty ([])No instructions needed
gas_limit21,000 (fixed)Always the same

What happens:

1. Verify signature ✓
2. Check sender.balance ≥ value + gas_fees ✓
3. sender.balance -= value
4. recipient.balance += value
5. sender.nonce += 1

Banking analogy: Opening a new automated escrow account with specific rules. You’re not sending money to someone — you’re creating a new rule-based account.

FieldValueMeaning
toNoneNo recipient — signals deployment
valueUsually 0Initial balance for contract (optional)
dataBytecodeThe contract’s compiled code
gas_limitVariableDepends on bytecode size + initialization

What happens:

1. Verify signature ✓
2. Compute contract address = hash(sender, nonce)
3. Create new account at that address
4. Store bytecode in contract account
5. Execute constructor (initialization code)
6. sender.nonce += 1

Key insight: The contract address is deterministic — you can know it before deploying!


Banking analogy: Triggering a standing instruction on a corporate account. You send a request (and optionally money), and the account’s pre-programmed rules execute.

FieldValueMeaning
toContract addressWhich contract to interact with
valueOptionalSend money along with the call
dataCalldataFunction selector + encoded arguments
gas_limitVariableDepends on what the code does

What happens:

1. Verify signature ✓
2. Load contract code from `to` address
3. Execute code with `data` as input
4. Code may: read/write storage, send funds, call other contracts
5. If success: state changes persist
6. If failure: all changes reverted (but gas still consumed!)

TransferDeployCall
toRecipientNoneContract
dataEmptyBytecodeCalldata
valueAmountInitial balancePayment to contract
GasFixed (21k)VariableVariable
Creates account?No*YesNo
Executes code?NoConstructor onlyYes

*Transfer creates recipient account if it doesn’t exist (with zero code)

We sign the hash of the transaction data (excluding the signature itself):

impl Transaction {
pub fn signing_hash(&self) -> Hash {
// Serialize everything except signature
let unsigned = UnsignedTransaction { /* ... */ };
let encoded = bincode::serialize(&unsigned).unwrap();
hash(&encoded)
}
pub fn sign(&mut self, keypair: &Keypair) {
let hash = self.signing_hash();
self.signature = keypair.sign_hash(&hash);
}
}

When deploying a contract, the address is deterministic:

pub fn contract_address(&self) -> Option<Address> {
if !self.is_deploy() {
return None;
}
// Address = hash(sender || nonce)[0..20]
let mut data = Vec::new();
data.extend_from_slice(&self.from.0);
data.extend_from_slice(&self.nonce.to_le_bytes());
let h = hash(&data);
// First 20 bytes
Some(Address(h.0[..20].try_into().unwrap()))
}

A block packages transactions into an ordered, tamper-evident unit.

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlockHeader {
pub height: u64,
pub timestamp: u64,
pub prev_hash: Hash,
pub merkle_root: Hash,
pub state_root: Hash,
pub author: Address,
pub difficulty: u64,
pub nonce: u64,
}

Straightforward — block number (0-indexed from genesis) and Unix timestamp when the block was produced.


Hash of the previous block’s header. This is what makes it a chain — each block cryptographically commits to its parent. Tamper with any historical block → all subsequent hashes change → immediately detectable.


A block can contain hundreds of transactions, but we represent them with a single 32-byte hash:

Merkle Tree Structure

Change any transaction → the root changes. This is a merkle tree — we’ll cover it in detail in section 1.6.


state_root — World State Commitment and the need of it.

Section titled “state_root — World State Commitment and the need of it.”

While merkle_root commits to the inputs (transactions), state_root commits to the outputs (resulting world state after execution).

Before Block 5: After Block 5:
┌────────────────────────┐ ┌────────────────────────┐
│ Alice: 100 Mini Coins │ │ Alice: 70 Mini Coins │ ← sent 30
│ Bob: 50 Mini Coins │ ──────► │ Bob: 80 Mini Coins │ ← received 30
│ Contract X: ... │ [txs] │ Contract X: ... │
└────────────────────────┘ └────────────────────────┘
↓ ↓
state_root_4 state_root_5
Use CaseHow state_root Helps
ConsensusNodes must agree on the result, not just the transactions. Same txs must produce same state_root, or something’s wrong.
Light clientsAsk “What’s Alice’s balance?” → get merkle proof against state_root. No need to download all accounts.
State syncNew node downloads current state, verifies against latest state_root. No need to replay all history from genesis.
Detecting bugsIf two nodes get different state_roots from same transactions → non-determinism bug (serious!).
Replay protectionCommits to all account nonces. Once a block is finalized, you can prove what nonces were valid at that height.

The key insight: merkle_root proves “these transactions were included.” state_root proves “executing them produced this exact world state.”

Is it built the same way as merkle_root?

Both are merkle roots, but the underlying structure differs:

merkle_rootstate_root
DataList of transactionsMap of address → account
StructureSimple merkle treeMerkle Patricia Trie
AccessSequential (all txs)Key-value lookup (by address)
UpdatesBuild fresh each blockIncremental (only changed accounts)

Transactions are a list — simple merkle tree works. Accounts are a key-value map with millions of entries — needs a trie (prefix tree) for efficient lookups and partial updates.

Why this matters: No need to replay history!

Without state_root, answering “What’s Alice’s balance?” would require:

Start at genesis → replay Block 1 → replay Block 2 → ... → replay Block 1,000,000
"Alice has 70 Mini Coins"

With state_root, you just:

Look up Alice in current state trie → verify proof against state_root
"Alice has 70 Mini Coins" ✓

The entire history is “compressed” into the latest state. You don’t trace back — you look up directly.

We’ll explore the state trie implementation in Chapter 2: Storage.


In a single-authority PoA, you might wonder: “We know who the authority is, why store author?”

Single authority: Technically redundant — every block is signed by the same authority.

Multiple rotating authorities:

Authorities = [Alice, Bob, Carol]
Block 0 → Alice's turn
Block 1 → Bob's turn
Block 2 → Carol's turn

Even here, you could derive author from height % num_authorities. But explicit author helps when:

ScenarioWhy author is useful
Authority skips slotIf Bob is offline, Alice produces block 1 instead. Can’t derive from height.
Dynamic authority setAuthorities added/removed over time. History needs to record who actually signed.
Self-describing blocksBlock makes sense without external context (which authority set was active at that height?).

Also: Ed25519 requires it. Unlike secp256k1 (Ethereum), you can’t recover the public key from an Ed25519 signature. So we need author to look up the correct public key for verification. (See Why Ed25519 Over secp256k1? for a detailed comparison.)

We’ll decide between single vs rotating authorities when implementing the consensus layer in Chapter 5.


These fields are Proof of Work concepts that don’t really apply to PoA:

In PoW (Bitcoin, old Ethereum):

difficulty = how hard the mining puzzle is
nonce = the value miners iterate to solve: hash(block + nonce) < target

Miners burn electricity trying billions of nonces until they find a valid hash. Higher difficulty = more attempts needed.

In PoA: No puzzle. The authority just signs. Both fields are meaningless.

So why include them?

Some PoA chains repurpose difficulty:

ChainRepurposed meaning
Clique (Geth)2 = in-turn validator, 1 = out-of-turn (helps fork choice)
Aura (Parity)Step number in rotation

For minichain: We keep them as placeholders for now. A truly minimal PoA chain could remove both — we may do so once we finalize the consensus design in Chapter 5.


A block can’t grow forever — we need limits. Different blockchains use different approaches:

ApproachHow It WorksUsed By
Byte sizeRaw data limit (e.g., 1MB)Bitcoin
Gas limitTotal computation budget per blockEthereum
Transaction countDirect cap on number of txsSome chains

Why gas limit is the best fit for smart contract chains:

Limit TypeProblem
Max transactions1000 complex contract calls could take minutes to execute
Max bytesSmall bytecode can still have expensive infinite loops
Max gasDirectly limits computation time, regardless of tx count or size

With a block gas limit, the math becomes predictable:

BLOCK_GAS_LIMIT = 30,000,000
Block can fit:
├─ ~1,428 simple transfers (21,000 gas each)
├─ OR ~60 complex contract calls (500,000 gas each)
└─ OR any mix where total_gas ≤ 30M

The block producer’s algorithm:

while let Some(tx) = mempool.next_by_gas_price() {
if block.total_gas() + tx.gas_limit <= BLOCK_GAS_LIMIT {
block.add(tx);
}
}

Higher gas_price = picked first. This is why fees spike when the network is busy — users outbid each other for limited block space.

Each block references its parent via prev_hash:

┌─────────┐ ┌─────────┐ ┌─────────┐
│ Block 0 │◄───│ Block 1 │◄───│ Block 2 │
│ Genesis │ │ │ │ │
└─────────┘ └─────────┘ └─────────┘
prev_hash=0 prev_hash= prev_hash=
hash(Block0) hash(Block1)

The genesis block is special — it has no parent:

impl Block {
pub fn genesis(authority: Address) -> Self {
Self {
header: BlockHeader {
height: 0,
timestamp: BlockHeader::current_timestamp(),
prev_hash: Hash::ZERO, // No parent!
merkle_root: Hash::ZERO,
state_root: Hash::ZERO,
author: authority,
difficulty: 1,
nonce: 0,
},
transactions: Vec::new(),
signature: Signature::default(),
}
}
}

In Proof of Authority, the designated authority signs each block:

impl Block {
pub fn sign(&mut self, keypair: &Keypair) {
let hash = self.header.hash();
self.signature = keypair.sign_hash(&hash);
}
pub fn verify_signature(&self, public_key: &PublicKey) -> bool {
let hash = self.header.hash();
public_key.verify(hash.as_bytes(), &self.signature).is_ok()
}
}

A merkle tree lets us summarize many items into a single hash, with efficient proofs.

Here’s how a 4-transaction merkle tree is constructed:

Transactions: T0 T1 T2 T3
│ │ │ │
↓ ↓ ↓ ↓
Leaf Hashes: H(T0) H(T1) H(T2) H(T3)
│ │ │ │
└────┬──────┘ └────┬──────┘
│ │
↓ ↓
Branch Hashes: H(H(T0)+H(T1)) H(H(T2)+H(T3))
│ │
└───────────┬───────────┘
Merkle Root: H(Branch0+Branch1)
(stored in block header)

Proof that T1 is in the block:

To prove T1 exists without providing T0, T2, T3:
Prover sends:
1. T1 (the transaction data)
2. H(T0) ← Sibling hash
3. H(T2+T3) ← Uncle hash
Verifier computes:
Step 1: H(T1) → get transaction hash
Step 2: H(T0) + H(T1) → compute H(T0,T1)
Step 3: H(T0,T1) + H(T2,T3) → compute Root
Step 4: Compare with block header's merkle_root
If match → T1 is in the block ✓
pub fn merkle_root(hashes: &[Hash]) -> Hash {
if hashes.is_empty() {
return Hash::ZERO;
}
if hashes.len() == 1 {
return hashes[0];
}
let mut current_level: Vec<Hash> = hashes.to_vec();
while current_level.len() > 1 {
let mut next_level = Vec::new();
for chunk in current_level.chunks(2) {
let combined = if chunk.len() == 2 {
hash_concat(&[chunk[0].as_ref(), chunk[1].as_ref()])
} else {
// Odd number: hash with itself
hash_concat(&[chunk[0].as_ref(), chunk[0].as_ref()])
};
next_level.push(combined);
}
current_level = next_level;
}
current_level[0]
}
  1. Integrity — Any change to any transaction changes the root
  2. Efficiency — Prove inclusion in O(log n) space
  3. Light clients — Verify transactions without full block data
pub struct MerkleProof {
pub leaf: Hash, // The item being proven
pub siblings: Vec<Hash>, // Sibling hashes on path to root
pub directions: Vec<bool>, // Left or right at each level
}

To verify: reconstruct the path from leaf to root using siblings.


We’ve built the cryptographic foundation:

ComponentWhat It Does
Hash256-bit Blake3 digest
Address160-bit identity derived from public key
KeypairEd25519 key pair for signing
AccountUser or contract state
TransactionSigned state change request
BlockOrdered batch of transactions
MerkleTreeEfficient transaction commitment
Terminal window
$ cargo test -p minichain-core
running 47 tests
test account::tests::test_new_user_account ... ok
test crypto::tests::test_sign_and_verify ... ok
test block::tests::test_genesis_block ... ok
test merkle::tests::test_merkle_proof_valid ... ok
# ... all 47 tests pass

With our primitives in place, we’re ready to build the storage layer in Chapter 2, where we’ll persist accounts, blocks, and contract state using sled.