Intro
In the previous article, we created a simple blockchain from scratch, covering the fundamentals of blocks, hashing, and chain validation, but we left our transactions in a very primitive way. So, today we'll fix them, but before we do, we need to dive deeper into one of the most critical components of any blockchain system: accounts and state management.
Accounting models
There are two most popular accounting models for blockchains: UTXO and account-based.
It's worth mentioning that Sui blockchain implements a different approach — an object-centric data model, where everything is an object, but to my taste it's more like a customised UTXO model than truly something new.
UTXO
UTXO (UTxO, Unspent Transaction Output) is an accounting model where transactions consume previous outputs as inputs and create new outputs, with no concept of account balances — only unspent outputs that can be used in future transactions. I know it sounds difficult to understand, but I'll show an example and it'll make things clear.
Imagine you have a wallet with paper money and coins. These are your UTXOs — you can't divide them (just like in real life, you can't cut a $5 bill and claim it's now $4.85). You can only use them whole for transactions. How do you know your balance? Simply sum up all your paper money and coins — or in blockchain terms, your UTXOs. Now, you want to buy a newspaper (create a transaction) that costs $2, but you only have a $5 bill, so you'll receive change — for example, three $1 bills.
Congratulations — now you understand how Bitcoin works! In the UTXO model, there are no accounts with balances. Instead, each transaction consumes previous outputs and creates new ones. Here are some blockchains that use this model:
- Bitcoin and similar blockchains like Litecoin (forked from Bitcoin's source code), Bitcoin Cash (a hard fork), Dogecoin (based on Litecoin), ZCash (forked from Bitcoin's source code) and others.
- Cardano (extended UTXO model with smart contract support)
- Monero
Now let's explore the pros and cons of the UTXO model.
Pros:
- High privacy — each transaction generates a new address, making it harder to trace wallet activity
- High scalability — parallel transaction processing significantly increases efficiency and throughput
- Easy validation — simply check whether a UTXO exists in the unspent set
Cons:
- Smart contracts — difficult to implement, though Cardano's extended UTXO model (eUTXO) provides smart contract support
- Balance fragmentation — wallets with many small UTXOs can struggle to transfer certain amounts in a single transaction due to input limits or this transaction will . For example, if I have 1000 UTXOs worth 0.001 COIN each and want to send 1 COIN, but can only include 900 UTXOs in a transaction.
- Hard to maintain wallet state — requires storing and indexing all unspent UTXOs to manage the wallet's balance.
Account-Based
Account-based is an accounting model where transactions directly modify account balances, maintaining a global state of all accounts with their current balances — similar to traditional banking systems.
Think of it like your bank account. When you receive your salary, the bank simply adds that amount to your account balance. When you buy something, they subtract the cost. Your balance is always stored and updated directly — there's no need to track individual bills or coins.
Here is our example with a newspaper:
In the account-based model, each account has a persistent balance that's updated with every transaction. Here are some blockchains that use this model:
- Ethereum and EVM-compatible chains like Polygon, Binance Smart Chain, Avalanche C-Chain, and many others
- Solana
- Tezos
Now let's explore the pros and cons of the account-based model.
Pros:
- Simplicity — easy to understand and implement, similar to traditional banking
- Smart contracts — naturally supports complex smart contract logic and state management
- Efficient for frequent transactions — no need to track multiple outputs, just update the balance
Cons:
- Lower privacy — all transactions are directly linked to account addresses, making activity easier to trace
- Replay attack vulnerability — requires nonces or sequence numbers to prevent transaction replay
- Sequential processing — transactions from the same account must be processed in order, limiting parallelisation
State
Now that we understand the two main accounting models, let's talk about state — one of the most fundamental concepts in blockchain architecture.
State represents the current snapshot of all data in the blockchain at a given moment. Think of it as a photograph of the entire system — all account balances, smart contract storage, and any other data that exists at a specific block height.
The way state is managed differs significantly between UTXO and account-based models.
UTXO
In UTXO-based blockchains, the state is the set of all unspent transaction outputs (the UTXO set). Each block modifies this set by:
- Consuming (removing) UTXOs used as transaction inputs
- Creating (adding) new UTXOs as transaction outputs
The current state can be calculated by starting from the genesis block and applying all transactions in sequence. However, most implementations maintain the UTXO set in memory or a database for quick access.
Account-Based
In account-based blockchains, the state is a mapping of account addresses to their current data, which typically includes:
- Balance — the amount of cryptocurrency the account holds
- Code — smart contract bytecode (if the account is a contract)
- Storage — persistent data used by smart contracts
Each transaction modifies the state by updating account balances or changing smart contract storage. The state is somehow stored in the block.
Implementation
For our Fleming blockchain, we'll use the account-based model because it's easier to understand and offers more interesting concepts to explore.
I'm continuing to update the codebase from the previous article, which you can find here.
First, define a type alias for the Address type:
pub type Address = String;
Now let's update our Transaction type:
#[derive(Debug, Clone)]
pub struct Transaction {
pub from: Address,
pub to: Address,
pub amount: u64,
}
Done! Now we're ready to create state for our blockchain. To keep things simple, let's define our state as a hashmap where the key is an address and the value is the balance of that address:
pub type State = HashMap<Address, u64>;
Then let’s add state to a block. For now, let’s store a full state in each block:
#[derive(Debug, Clone)]
pub struct Block {
number: u64, // just a serial number of block
transactions: Vec<Transaction>, // array of transactions
state: State, // FULL state of blockchain
previous_hash: BlockHash, // SHA-256 of previous block
hash: BlockHash, // SHA-256 of current block
timestamp: u64, // block creation timestamp
}
Update the calculate_hash method to include state in block hash calculations (this prevents compromising the blockchain by tampering with state):
fn calculate_hash(&self) -> BlockHash {
let mut hasher = Sha256::new();
hasher.update(self.number.to_le_bytes());
hasher.update(&self.previous_hash.0);
hasher.update(self.timestamp.to_le_bytes());
for tx in &self.transactions {
hasher.update(tx.as_bytes());
}
// Since HashMap has indeterministic order, lets convert it to an array and sort by keys
let mut state_entries: Vec<_> = self.state.iter().collect();
state_entries.sort_by(|a, b| a.0.cmp(b.0));
for (address, balance) in state_entries {
hasher.update(address.as_bytes());
hasher.update(balance.to_le_bytes());
}
BlockHash(hasher.finalize().into())
}
Now we need to process transactions by checking the blockchain's state:
pub fn append_block(&mut self, transactions: Vec<Transaction>) {
let previous_block = self.chain.last().unwrap();
let previous_hash = previous_block.hash().clone();
let block_number = previous_block.number() + 1;
// clone full state
let mut new_state = previous_block.state().clone();
// apply all transactions to the state
for tx in &transactions {
let from_balance = new_state.get(&tx.from).unwrap_or(&0);
if *from_balance < tx.amount {
// will handle correctly later
panic!("Insufficient balance!");
}
*new_state.get_mut(&tx.from).unwrap() -= tx.amount;
*new_state.entry(tx.to.clone()).or_insert(0) += tx.amount;
}
let new_block = Block::new(block_number, transactions, new_state, previous_hash);
println!("Appending block: {:#?}", new_block);
self.chain.push(new_block);
}
The transaction processing logic iterates through each transaction and updates the state. First, it verifies that the sender has sufficient balance to cover the transaction amount and panicking if they don't. I know this isn't a good way to handle such cases, and we'll fix it later, but for now it's fine. Then it deducts the amount from the sender's balance and adds it to the recipient's balance, creating a new entry if the recipient doesn't exist in the state yet. To validate that our new logic works correctly, let's update our unit tests and add a couple more:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_blockchain_creation_with_state() {
let blockchain = Blockchain::new(vec![(String::from("A"), 10), (String::from("B"), 5)]);
assert_eq!(blockchain.chain.len(), 1);
assert!(blockchain.is_valid());
let genesis_block = &blockchain.chain.last().unwrap();
assert_eq!(genesis_block.number(), 0);
assert_eq!(*genesis_block.state().get("A").unwrap(), 10);
assert_eq!(*genesis_block.state().get("B").unwrap(), 5);
}
#[test]
fn test_valid_blockchain_and_state_after_transactions() {
let mut blockchain = Blockchain::new(vec![(String::from("A"), 10), (String::from("C"), 5)]);
blockchain.append_block(vec![Transaction::new(
String::from("A"),
String::from("B"),
10,
)]);
blockchain.append_block(vec![Transaction::new(
String::from("C"),
String::from("D"),
5,
)]);
assert_eq!(blockchain.chain.len(), 3);
assert!(blockchain.is_valid());
// initial: A=10, C=5
// A -(10)-> B: A=0, B=10
// C -(5)-> D: C=0, D=5
let last_block = &blockchain.chain.last().unwrap();
assert_eq!(last_block.number(), 2);
assert_eq!(*last_block.state().get("A").unwrap(), 0);
assert_eq!(*last_block.state().get("B").unwrap(), 10);
assert_eq!(*last_block.state().get("C").unwrap(), 0);
assert_eq!(*last_block.state().get("D").unwrap(), 5);
}
#[test]
#[should_panic(expected = "Insufficient balance")]
fn test_insufficient_balance_panics() {
let mut blockchain = Blockchain::new(vec![(String::from("A"), 10)]);
blockchain.append_block(vec![Transaction::new(
String::from("A"),
String::from("B"),
15,
)]);
}
#[test]
fn test_tampered_transaction_blockchain_invalid() {
let mut blockchain = Blockchain::new(vec![(String::from("A"), 10), (String::from("C"), 5)]);
blockchain.append_block(vec![Transaction::new(
String::from("A"),
String::from("B"),
10,
)]);
blockchain.append_block(vec![Transaction::new(
String::from("C"),
String::from("D"),
5,
)]);
blockchain.chain[1]
.tamper_transaction(0, Transaction::new(String::from("A"), String::from("B"), 2));
assert!(!blockchain.is_valid());
}
#[test]
fn test_tampered_state_blockchain_invalid() {
let mut blockchain = Blockchain::new(vec![(String::from("A"), 10), (String::from("C"), 5)]);
blockchain.append_block(vec![Transaction::new(
String::from("A"),
String::from("B"),
10,
)]);
blockchain.append_block(vec![Transaction::new(
String::from("C"),
String::from("D"),
5,
)]);
blockchain.chain[1].tamper_state(String::from("F"), 15);
assert!(!blockchain.is_valid());
}
}
The test suite validates the blockchain's state management and transaction processing capabilities. Tests verify that the genesis block initialises with the correct state, transactions properly update account balances, and the blockchain maintains validity after multiple transactions. The suite also ensures that insufficient balance scenarios are caught and that any tampering with either transactions or state makes the blockchain invalid. Finally, tests confirm that the cryptographic hashing properly detects modifications to both transaction data and state data.
Conclusion
In this article, we've taken a significant step forward by implementing accounts and state management in our Fleming blockchain. We explored the two main accounting models used in blockchain systems — UTXO and account-based — and chose the account-based model for its simplicity and flexibility with smart contracts.
We successfully implemented:
- Account addresses and transaction structure with sender, recipient, and amount
- State management as a hashmap of account balances
- Transaction processing logic that validates balances and updates state
- Comprehensive tests to ensure our blockchain maintains validity
However, our current implementation has two critical problems that we need to address:
- Memory inefficiency: Storing the full state in every block is extremely wasteful and doesn't scale well. As the blockchain grows and the number of accounts increases, each block becomes larger and larger, consuming excessive memory and making the blockchain impractical for real-world use.
- Security vulnerability: Currently, anyone can create transactions from any account without proving ownership. There's no authentication mechanism to verify that the person sending a transaction actually owns the account they're sending from. This is a massive security flaw that would allow anyone to steal funds from any account.
In the next article, we'll tackle the second problem by implementing wallets and cryptographic signatures. We'll explore how public-key cryptography works, how to generate wallet addresses, and most importantly, how to ensure that only the legitimate owner of an account can authorise transactions from that account. This will make our blockchain secure and ready for more advanced features.
As usual all the source code from the article can be found here.
Stay tuned!


Top comments (0)