X-Change Guide

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.

apps/node/src/chain/blockchain.rs
1pub struct Block {
2 pub index: i64,
3 pub hash: String,
4 pub prev_hash: String,
5 pub merkle_root: String,
6 pub proposer: String,
7 pub tx_hashes: Vec<String>,
8 pub 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:

Live: build a Merkle tree

In a block, the list of transactions is summarised into a single 64-char Merkle root. Hashing is done in pairs, level by level.

tx-atx-btx-ctx-d
8102aa5c6c28190cbcec62fcec18d2aa4866fc476d122482
f0683789eb93bd7128f6658d
cd31c5b2f295

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 + 1 and prev_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.
Running state inside the block
Because two txs from the same sender can appear in one block, validation runs a local copy of balances/nonces that updates as it goes. This is how a double-spend (two txs, same nonce) inside one block is caught — the second tx finds the nonce already used.

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:

Tick (every 3s)
tokio::time::interval
Epoch boundary?
height % 100 == 0 (and height > 0)
yes → distribute rewards + release cooldowns
Grab pending txs
mempool, ordered by fee + priority_fee DESC
up to MAX_TXS_PER_BLOCK (500)
Pick proposer
select_proposer(validators, height+1)
Is it our turn?
no validators / 1 validator / proposed == node
no → skip this tick (single-node demo always proposes)
Validate each tx in order
signature + running balance/nonce
invalid ones → status='failed' in DB
Build + persist block
validate_block → apply_block → insert
INSERT block, mark txs confirmed, persist balances/nonces

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:

apps/node/src/chain/consensus.rs
1pub fn select_proposer(validators: &[(String, i64)], height: i64) -> Option<String> {
2 let total: i64 = validators.iter().map(|(_, s)| s).sum();
3 if validators.is_empty() || total <= 0 { return None; }
4 let seed = sha2hex(&format!("proposer|{}|{}", height, total));
5 let pick = u128::from_str_radix(&seed[..16], 16).unwrap_or(0) % total as u128;
6 let mut acc: i128 = 0;
7 for (addr, stake) in validators {
8 acc += *stake as i128;
9 if (pick as i128) < acc { return Some(addr.clone()); }
10 }
11 Some(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).
Live: stake-weighted proposer selection

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%PROPOSER

Epochs, 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).
process_epoch — reward splitting (i128 math avoids overflow)
1for v in &validators {
2 let reward = (total_reward as i128 * v.stake as i128 / total_stake as i128) as i64;
3 if reward <= 0 { continue; }
4 // credit v.address with [reward], bump validators.rewards
5}

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

Defense summary — say this
"A block is a data structure whose hash includes the previous block's hash, so the chain can't be rewritten unnoticed. Every 3 seconds the node builds a block from mempool transactions, validating signatures, balances and nonces with a pure function. Who gets to propose is decided deterministically by a stake-weighted hash lottery."