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.
What We’re Building
Section titled “What We’re Building”By the end of this chapter, you’ll have implemented:
| Module | Purpose |
|---|---|
hash.rs | Blake3 hashing utilities |
crypto.rs | Ed25519 signatures and addresses |
account.rs | Account state representation |
transaction.rs | Transaction types and signing |
block.rs | Block and header structures |
merkle.rs | Merkle tree for transaction proofs |
1.1 Hashing with Blake3
Section titled “1.1 Hashing with Blake3”Hashing is the heartbeat of a blockchain. We use it for:
- Block identification
- Transaction fingerprinting
- Merkle tree construction
- Address derivation
Why Blake3?
Section titled “Why Blake3?”| Algorithm | Speed | Security | Output |
|---|---|---|---|
| SHA-256 | Moderate | Proven | 32 bytes |
| Keccak-256 | Moderate | Proven | 32 bytes |
| Blake3 | Very Fast | Modern | 32 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.
The Hash Type
Section titled “The Hash Type”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)) }}Hashing Functions
Section titled “Hashing Functions”/// 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())}1.2 Cryptographic Identities
Section titled “1.2 Cryptographic Identities”Digital signatures let us prove ownership without revealing secrets. We use Ed25519 for its speed and compact 64-byte signatures.
Why Ed25519 Over secp256k1?
Section titled “Why Ed25519 Over secp256k1?”Ethereum uses secp256k1 (the same curve as Bitcoin). So why does Minichain choose Ed25519?
The Two Main Signature Schemes
Section titled “The Two Main Signature Schemes”| Feature | Ed25519 | secp256k1 (Ethereum) |
|---|---|---|
| Signature Size | 64 bytes | 64-65 bytes |
| Public Key Size | 32 bytes | 33-65 bytes (compressed/uncompressed) |
| Speed | Very fast (~5-10x faster signing) | Moderate |
| Public Key Recovery | ❌ No (needs explicit pubkey) | ✅ Yes (from signature + message) |
| Security Level | ~128 bits | ~128 bits |
| Standardization | Modern (2011) | Older (Bitcoin-era) |
| Use Cases | SSH, TLS, Signal | Bitcoin, Ethereum, most blockchains |
Key Trade-off: Public Key Recovery
Section titled “Key Trade-off: Public Key Recovery”The biggest difference is public key recovery:
secp256k1 (Ethereum):
// Ethereum can recover the signer's public key from just the signaturelet signature = sign(message, private_key);let public_key = recover_pubkey(signature, message); // ✓ Possiblelet address = hash(public_key); // Derive address from recovered pubkeyEd25519 (Minichain):
// Ed25519 requires the public key to verifylet signature = sign(message, private_key);// Cannot recover public key from signature alone!// Must store/provide public key explicitly for verificationverify(message, signature, public_key); // ✓ Requires pubkeyWhy This Matters for Blockchain
Section titled “Why This Matters for Blockchain”Ethereum’s Advantage (secp256k1):
- Transactions only need signature (not public key) → smaller
- Block headers don’t need
authorfield → 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.
Why Ethereum Chose secp256k1
Section titled “Why Ethereum Chose secp256k1”Why Minichain Chooses Ed25519
Section titled “Why Minichain Chooses Ed25519”For a learning blockchain, the advantages are clear:
| Priority | Why Ed25519 Wins |
|---|---|
| Simplicity | No complex recovery algorithm to implement |
| Performance | 5-10x faster signing (better for testing/development) |
| Modern best practices | Recommended by cryptographers for new systems |
| Small overhead | Explicit author field is only ~20 bytes per block |
Implementation in Minichain
Section titled “Implementation in Minichain”Minichain uses battle-tested Rust crates for cryptography:
For Ed25519 signatures:
- Crate:
ed25519-dalekversion 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:
blake3version 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.
Address Derivation
Section titled “Address Derivation”An address is derived from a public key:
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 }}The Keypair
Section titled “The Keypair”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()) }}Usage Example
Section titled “Usage Example”// Generate a new identitylet keypair = Keypair::generate();println!("Address: {}", keypair.address());
// Sign a messagelet message = b"hello blockchain";let signature = keypair.sign(message);
// Verify the signatureassert!(keypair.public_key.verify(message, &signature).is_ok());1.3 Account State
Section titled “1.3 Account State”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,}Two Types of Accounts
Section titled “Two Types of Accounts”// Externally Owned Account - controlled by a private keylet user = Account::new_user(1000);assert!(user.is_eoa());assert_eq!(user.code_hash, None);// Contract account - has associated bytecodelet code_hash = hash(b"contract bytecode");let contract = Account::new_contract(code_hash);assert!(contract.is_contract());assert_eq!(contract.code_hash, Some(code_hash));Why Two Types of Accounts?
Section titled “Why Two Types of Accounts?”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
🏢 Contract Account
Section titled “🏢 Contract Account”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
Why Do We Need Both?
Section titled “Why Do We Need Both?”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.
Nonce: Preventing Replay Attacks
Section titled “Nonce: Preventing Replay Attacks”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.
🧾 Cheque Number (Banking)
Section titled “🧾 Cheque Number (Banking)”- 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
🔢 Nonce (Blockchain)
Section titled “🔢 Nonce (Blockchain)”- 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
1.4 Transactions
Section titled “1.4 Transactions”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.
🏦 Wire Transfer Fee (Banking)
Section titled “🏦 Wire Transfer Fee (Banking)”- Fee cap: “I’ll pay up to $50 for this transfer”
- Priority rate: Standard (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 (Blockchain)
Section titled “⛽ Gas (Blockchain)”gas_limit: Maximum gas units you’re willing to consumegas_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.
| Field | What It Measures | Who Controls It |
|---|---|---|
gas_limit | Computation — how much work | The operation itself (deterministic) |
gas_price | Priority — how fast you want it | You (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)
Why separation matters
Section titled “Why separation matters”-
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.
-
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.
-
Market flexibility — Network busy? Raise gas_price, not gas_limit. Your operation doesn’t need more computation, just faster inclusion.
-
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 nonceSame steps every time = same gas every time.
So why have gas_limit at all?
| Transaction Type | Gas Usage |
|---|---|
| EOA → EOA transfer | Fixed: 21,000 |
| Contract deployment | Variable: depends on bytecode size |
| Contract call | Variable: 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.
Why Gas Exists
Section titled “Why Gas Exists”| Problem | How Gas Solves It |
|---|---|
| Infinite loops in contracts | Execution stops when gas runs out |
| Spam transactions | Every operation costs money |
| Resource fairness | Users pay for computation they consume |
| Miner/validator incentives | Gas fees reward block producers |
Quick Reference
Section titled “Quick Reference”| Field | What It Means | Banking Equivalent |
|---|---|---|
gas_limit | Max gas you’ll spend | Fee budget cap |
gas_price | Cost per gas unit | Priority/speed tier |
gas_used | Actual gas consumed | Actual fee charged |
gas_limit × gas_price | Max possible cost | Worst-case fee |
Transaction Types
Section titled “Transaction Types”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:
| From | To | Type | What Happens |
|---|---|---|---|
| Human | Human | Transfer | Money moves, nothing else |
| Human | New Machine | Deploy | A new autonomous program is born |
| Human | Existing Machine | Call | The 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.
Why this matters
Section titled “Why this matters”-
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.
-
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
toisNone— there’s no recipient yet; you’re creating one. -
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).
One Struct, Three Interpretations
Section titled “One Struct, Three Interpretations”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 Transferlet 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 Calllet call = Transaction::call(from, contract, calldata, value, nonce, gas_limit, gas_price);1️⃣ Value Transfer (EOA → EOA)
Section titled “1️⃣ Value Transfer (EOA → EOA)”Banking analogy: A simple bank transfer — move money from your account to someone else’s.
| Field | Value | Meaning |
|---|---|---|
to | Recipient address | Who receives the money |
value | Amount | How much to send |
data | Empty ([]) | No instructions needed |
gas_limit | 21,000 (fixed) | Always the same |
What happens:
1. Verify signature ✓2. Check sender.balance ≥ value + gas_fees ✓3. sender.balance -= value4. recipient.balance += value5. sender.nonce += 12️⃣ Contract Deployment
Section titled “2️⃣ Contract Deployment”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.
| Field | Value | Meaning |
|---|---|---|
to | None | No recipient — signals deployment |
value | Usually 0 | Initial balance for contract (optional) |
data | Bytecode | The contract’s compiled code |
gas_limit | Variable | Depends on bytecode size + initialization |
What happens:
1. Verify signature ✓2. Compute contract address = hash(sender, nonce)3. Create new account at that address4. Store bytecode in contract account5. Execute constructor (initialization code)6. sender.nonce += 1Key insight: The contract address is deterministic — you can know it before deploying!
3️⃣ Contract Call
Section titled “3️⃣ Contract Call”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.
| Field | Value | Meaning |
|---|---|---|
to | Contract address | Which contract to interact with |
value | Optional | Send money along with the call |
data | Calldata | Function selector + encoded arguments |
gas_limit | Variable | Depends on what the code does |
What happens:
1. Verify signature ✓2. Load contract code from `to` address3. Execute code with `data` as input4. Code may: read/write storage, send funds, call other contracts5. If success: state changes persist6. If failure: all changes reverted (but gas still consumed!)Quick Comparison
Section titled “Quick Comparison”| Transfer | Deploy | Call | |
|---|---|---|---|
to | Recipient | None | Contract |
data | Empty | Bytecode | Calldata |
value | Amount | Initial balance | Payment to contract |
| Gas | Fixed (21k) | Variable | Variable |
| Creates account? | No* | Yes | No |
| Executes code? | No | Constructor only | Yes |
*Transfer creates recipient account if it doesn’t exist (with zero code)
Signing Transactions
Section titled “Signing Transactions”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); }}Contract Address Derivation
Section titled “Contract Address Derivation”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()))}1.5 Blocks
Section titled “1.5 Blocks”A block packages transactions into an ordered, tamper-evident unit.
Block Header
Section titled “Block Header”#[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,}Need of height and timestamp
Section titled “Need of height and timestamp”Straightforward — block number (0-indexed from genesis) and Unix timestamp when the block was produced.
prev_hash — The Chain Link
Section titled “prev_hash — The Chain Link”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.
merkle_root — Transaction Commitment
Section titled “merkle_root — Transaction Commitment”A block can contain hundreds of transactions, but we represent them with a single 32-byte hash:
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 Case | How state_root Helps |
|---|---|
| Consensus | Nodes must agree on the result, not just the transactions. Same txs must produce same state_root, or something’s wrong. |
| Light clients | Ask “What’s Alice’s balance?” → get merkle proof against state_root. No need to download all accounts. |
| State sync | New node downloads current state, verifies against latest state_root. No need to replay all history from genesis. |
| Detecting bugs | If two nodes get different state_roots from same transactions → non-determinism bug (serious!). |
| Replay protection | Commits 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_root | state_root | |
|---|---|---|
| Data | List of transactions | Map of address → account |
| Structure | Simple merkle tree | Merkle Patricia Trie |
| Access | Sequential (all txs) | Key-value lookup (by address) |
| Updates | Build fresh each block | Incremental (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.
author — Block Producer
Section titled “author — Block Producer”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 turnBlock 1 → Bob's turnBlock 2 → Carol's turnEven here, you could derive author from height % num_authorities. But explicit author helps when:
| Scenario | Why author is useful |
|---|---|
| Authority skips slot | If Bob is offline, Alice produces block 1 instead. Can’t derive from height. |
| Dynamic authority set | Authorities added/removed over time. History needs to record who actually signed. |
| Self-describing blocks | Block 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.
difficulty and nonce — PoW Artifacts
Section titled “difficulty and nonce — PoW Artifacts”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 isnonce = the value miners iterate to solve: hash(block + nonce) < targetMiners 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:
| Chain | Repurposed 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.
Block Size: How Many Transactions Fit?
Section titled “Block Size: How Many Transactions Fit?”A block can’t grow forever — we need limits. Different blockchains use different approaches:
| Approach | How It Works | Used By |
|---|---|---|
| Byte size | Raw data limit (e.g., 1MB) | Bitcoin |
| Gas limit | Total computation budget per block | Ethereum |
| Transaction count | Direct cap on number of txs | Some chains |
Why gas limit is the best fit for smart contract chains:
| Limit Type | Problem |
|---|---|
| Max transactions | 1000 complex contract calls could take minutes to execute |
| Max bytes | Small bytecode can still have expensive infinite loops |
| Max gas | Directly 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 ≤ 30MThe 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.
The Chain of Blocks
Section titled “The Chain of Blocks”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)Genesis Block
Section titled “Genesis Block”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(), } }}Block Signing (PoA)
Section titled “Block Signing (PoA)”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() }}1.6 Merkle Trees
Section titled “1.6 Merkle Trees”A merkle tree lets us summarize many items into a single hash, with efficient proofs.
Merkle Tree Structure
Section titled “Merkle Tree Structure”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 ✓Computing the Root
Section titled “Computing the Root”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]}Why Merkle Trees?
Section titled “Why Merkle Trees?”- Integrity — Any change to any transaction changes the root
- Efficiency — Prove inclusion in O(log n) space
- Light clients — Verify transactions without full block data
Merkle Proofs
Section titled “Merkle Proofs”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.
Summary
Section titled “Summary”We’ve built the cryptographic foundation:
| Component | What It Does |
|---|---|
Hash | 256-bit Blake3 digest |
Address | 160-bit identity derived from public key |
Keypair | Ed25519 key pair for signing |
Account | User or contract state |
Transaction | Signed state change request |
Block | Ordered batch of transactions |
MerkleTree | Efficient transaction commitment |
Tests Passing
Section titled “Tests Passing”$ cargo test -p minichain-core
running 47 teststest account::tests::test_new_user_account ... oktest crypto::tests::test_sign_and_verify ... oktest block::tests::test_genesis_block ... oktest merkle::tests::test_merkle_proof_valid ... ok# ... all 47 tests passWhat’s Next?
Section titled “What’s Next?”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.