Blockchain & consensus
This is the heart of the "blockchain" part. Three pieces: the block structure, the validation rules, and the block producer loop that creates blocks every 3 seconds.
The Block struct
A block is a plain data structure. It has an index (its number), a hash, the previous block's hash (the chain link), a Merkle root (summary of transactions), the proposer and a timestamp.
1pub struct Block {2pub index: i64,3pub hash: String,4pub prev_hash: String,5pub merkle_root: String,6pub proposer: String,7pub tx_hashes: Vec<String>,8pub created_at: i64,9}
Block hash
The hash is SHA-256 of a canonical string of all fields. Because prev_hash is inside, changing any block would change its hash — and the next block's prev_hash would no longer match. Tamper with history and the whole chain breaks.
Merkle root
Blocks don't store transactions inline in the DB — they store the Merkle root and a count, and transactions reference the block by block_index. The root lets a block summarise an arbitrary-length list in exactly 64 characters. Try it below:
In a block, the list of transactions is summarised into a single 64-char Merkle root. Hashing is done in pairs, level by level.
Change the transaction list and watch the root change. The node recomputes this root when it validates every block — if one transaction was tampered with, the root won't match and the block is rejected.
How a block is validated
validate_block is a pure function — no database. It takes the candidate block, the previous block, the transactions, and a snapshot of balances + nonces, and returns an error on the first problem:
block.hash == compute_hash()— the block hasn't been altered.index == prev.index + 1andprev_hash == prev.hash— it chains correctly.merkle_root == merkle(tx_hashes)— the transaction list is intact.- Each tx (in order): signature valid, sender has balance, nonce matches, fee ≥ minimum.
After validation, apply_block produces the new state: sender loses amount + fee + priority_fee, recipient gains amount (or nothing if the zero-address — a burn), the proposer earns the fees, and each sender's nonce advances.
The block producer loop
A background task runs on a tokio::time::interval every 3 seconds. Each tick it tries to produce one block:
The empty-block detail
If no transactions are valid, no block is produced — the height stays the same until a later tick has something to include. Empty blocks are skipped (simpler and saves disk; the demo doesn't need tick-empty blocks).
Consensus: who proposes?
Proof of Stake ties block-production rights to stake. The selection is deterministic — the same inputs give the same proposer on every node, no voting needed:
1pub fn select_proposer(validators: &[(String, i64)], height: i64) -> Option<String> {2let total: i64 = validators.iter().map(|(_, s)| s).sum();3if validators.is_empty() || total <= 0 { return None; }4let seed = sha2hex(&format!("proposer|{}|{}", height, total));5let pick = u128::from_str_radix(&seed[..16], 16).unwrap_or(0) % total as u128;6let mut acc: i128 = 0;7for (addr, stake) in validators {8acc += *stake as i128;9if (pick as i128) < acc { return Some(addr.clone()); }10}11Some(validators.last().unwrap().0.clone())12}
- Seed =
sha256("proposer|{height}|{total_stake}"). - First 16 hex chars → a number →
mod total stake. - Walk validators by cumulative stake; the one that crosses the number wins.
- A validator with 70% of stake wins ~70% of the time (weighted lottery).
The node picks who produces the next block with a deterministic lottery: sha256("proposer|height|totalStake") turned into a number, walked through validators in proportion to their stake. The same height always picks the same proposer on every node.
0xaaa10%0xbbb20%0xccc70%PROPOSEREpochs, rewards, cooldowns
An epoch is 100 blocks (≈5 minutes). At every epoch boundary the producer calls staking::process_epoch, which:
- Mints
EPOCH_REWARD(0.5 XCH) and splits it among active validators proportional to stake. - Releases any unstaked positions whose cooldown epoch has arrived (escrow back to the staker).
1for v in &validators {2let reward = (total_reward as i128 * v.stake as i128 / total_stake as i128) as i64;3if reward <= 0 { continue; }4// credit v.address with [reward], bump validators.rewards5}
Genesis
When the chain is empty (no rows in blocks), boot creates block 0: prev_hash = "0"×64, merkle_root = "0"×64, no transactions. It also seeds the faucet account with 10,000 XCH so the faucet can mint.
How "the chain" is stored
There is no special chain file — everything is rows in Postgres. The block producer reads the latest block, and the API reads blocks back out. The chain's "truth" is whatever the DB holds. Database → see the blocks & transactions tables