A minimal, deterministic account-based ledger and blockchain state machine written in Rust. The project explores protocol fundamentals—transaction identity, ledger invariants, state commitments, and atomic block execution—without networking, consensus, or cryptography.
This is a systems learning lab, not a production blockchain. It implements a single-node chain where:
- Accounts hold token balances and send nonces.
- Transfers are validated and applied atomically.
- Blocks batch transactions and commit to a verifiable final ledger state.
- Every operation returns explicit errors; failed operations leave state unchanged.
The goal is to make protocol rules concrete: deterministic state transitions, content-derived transaction IDs, conservation of supply, and all-or-nothing block execution. See the notes/ directory for deeper write-ups on ledger invariants, transaction identity, and atomic block execution.
The crate is organized as a layered state machine:
Transaction → Ledger → Block → Blockchain
│ │ │ │
validate apply tx header append_block
hash_id state_commit hash (atomic)
| Module | Role |
|---|---|
src/account.rs |
Account struct (balance, nonce) |
src/transaction.rs |
Transfer payload, structural validation, canonical encoding, content-derived ID |
src/ledger.rs |
Account map, create_account, apply_transaction (validate-then-mutate), state_commitment |
src/block.rs |
Block, BlockHeader, transaction commitment, block hash |
src/blockchain.rs |
Chain storage and append_block (the only way to advance state) |
src/hash.rs |
Canonical bincode encoding and SHA-256 hashing |
src/error.rs |
Typed errors for each layer |
A Blockchain holds two pieces of live state:
blocks: Vec<Block>— ordered chain historyledger: Ledger— current account balances and nonces
append_block is the sole entry point for advancing the chain. There is no networking, mempool, or persistence layer.
Each account is identified by a unique string ID and stores:
| Field | Type | Meaning |
|---|---|---|
balance |
u64 |
Tokens held by the account |
nonce |
u64 |
Number of transfers already sent from this account |
Rules:
- Accounts are created explicitly via
Ledger::create_account(id, balance)with an initial nonce of0. - Account IDs must be unique; duplicate creation returns
LedgerError::AccountAlreadyExists. - Only the sender nonce advances (by exactly 1) on a successful transfer. Receiving does not change the receiver's nonce.
- Balances are unsigned (
u64); they cannot go negative.
A Transaction has four fields: sender, receiver, amount, and nonce.
Before touching the ledger, Validate::validate checks:
amount > 0→ZeroAmountsender != receiver→SelfTransfer
Ledger::apply_transaction checks:
- Sender and receiver accounts exist
transaction.nonce == sender.noncesender.balance >= amountreceiver.balance + amountdoes not overflowu64
All checks run inside an immutable-borrow block before any mutation.
On success:
- Sender balance decreases by
amount; sender nonce increases by 1 - Receiver balance increases by
amount; receiver nonce is unchanged
On failure, the ledger is unchanged (validate-then-mutate).
Transaction IDs are content-derived, not assigned:
Transaction → canonical_bytes() → SHA-256 → HashedId
Encoding uses bincode with config::standard(). Identical content always produces the same ID on every machine.
Transactions are ordered in a block's transactions vector. The block header's transaction_commitment is the SHA-256 hash of each transaction's hash_id, concatenated in order.
Blockchain::append_block validates and executes a block atomically:
- Height —
header.heightmust equalblocks.len() - Previous hash — must match the last block's hash (or
[0; 32]for the first block) - Transaction commitment — must match
Block::transaction_commitment(&transactions) - Unique transaction IDs — no duplicate
hash_idvalues within the block - Atomic execution — clone the live ledger, apply every transaction in order; abort on first failure
- State commitment — computed
ledger.state_commitment()must matchheader.state_commitment - Supply invariant —
total_supply()before and after must be equal - Commit — only if all checks pass: replace
self.ledgerand push the block
A block either fully succeeds (all transactions apply, ledger and chain both update) or fully fails (ledger and blocks are identical to before the call). There is no partial application.
This is enforced by executing against a temporary ledger clone and committing once. See notes/atomic-block-execution.md for the rationale.
| Field | Meaning |
|---|---|
height |
Block index in the chain |
previous_hash |
Hash of the previous block header |
transaction_commitment |
Commitment to the ordered transaction list |
state_commitment |
SHA-256 of the sorted account map after all transactions succeed |
state_commitment sorts accounts by ID, encodes with canonical bincode, and hashes with SHA-256. Insertion order into the HashMap does not affect the result.
These properties must hold at all times. Violations cause operations to be rejected rather than partially applied.
- Unique account IDs
- Non-negative balances (
u64) - Initial nonce is zero
- Nonce advances only on send
- Positive amount, distinct sender/receiver
- Correct nonce, sufficient balance, no overflow
- Atomic: all balance and nonce updates succeed together or none happen
- Conservation of supply —
total_supply()is unchanged by valid transfers - No partial updates — validation completes before mutation
- Block header fields match computed values
- No duplicate transaction IDs within a block
- Final ledger matches
state_commitmentin the header - Total supply unchanged across block execution
- Blocks are strictly ordered by height with a valid hash chain
- Failed blocks leave both
ledgerandblocksunchanged
Tests live in tests/ as integration tests (50 tests total). There are no unit tests in src/; coverage is organized by layer:
| File | Focus |
|---|---|
tests/ledger_tests.rs |
Account creation, transfer validation, supply conservation, no-mutation on error |
tests/transaction_tests.rs |
Canonical encoding, content-derived IDs, serialization round-trip |
tests/block_tests.rs |
Block header hashing sensitivity |
tests/state_commitment_tests.rs |
Deterministic state commitment across insertion order |
tests/blockchain_tests.rs |
Block validation, atomic execution, no-mutation on failure |
Every error path asserts the no-mutation property: after a rejected operation, balances, nonces, block count, and state commitment are unchanged. This mirrors how protocol software verifies state machine safety.
Install Rust (stable toolchain). Open PowerShell or Command Prompt in the project root.
cargo buildcargo testcargo clippy --all-targets --all-features -- -D warningscargo fmt --all -- --checkTo apply formatting instead of checking:
cargo fmt --all- Single node only — no networking, peer sync, or consensus (PoW, PoS, etc.)
- No cryptography — transactions are not signed; account IDs are plain strings, not public keys
- No mempool — blocks are constructed and submitted directly; there is no transaction pool
- No persistence — state lives in memory; restarting the process loses the chain
- No genesis protocol — the first block is a convention in tests, not a hard-coded genesis spec
- No fees or rewards — transfers move existing tokens only; block producers are not compensated
- No smart contracts — only simple balance transfers are supported
- Empty binary —
src/main.rsis a stub; the library is the product
Planned extensions, roughly in dependency order:
- Cryptographic accounts — key pairs, signed transactions, address derivation from public keys
- Genesis block spec — fixed initial state and header constants instead of ad-hoc test setup
- Mempool — accept, validate, and order pending transactions before block inclusion
- Block builder — construct valid blocks from mempool contents with correct commitments
- Persistence — serialize and reload chain state from disk
- CLI or REPL — interactive commands to create accounts, submit transactions, and inspect the chain
- Multi-node simulation — in-process nodes exchanging blocks to exercise determinism and fork handling
- Consensus stub — longest-chain or simple BFT rules to choose the canonical fork
Contributions and experiments that stay focused on correctness and determinism are welcome.
This lab implements a simplified single-map ledger. Solana uses a runtime-managed account model with signed messages and program-scoped execution. The table below maps our types to Solana concepts. These are learning analogies, not claims of equivalence. See notes/solana-account-model.md and notes/solana-transaction-anatomy.md for detail.
| Your project | Solana concept | Critical difference |
|---|---|---|
| Account | Solana account | Solana accounts contain owner, data and executable semantics |
| Transaction | Solana transaction/message | Solana transactions contain signatures and compiled instructions |
apply_transaction |
Program instruction processing | Programs receive explicitly declared accounts |
| Ledger state | Runtime-managed accounts | No user program owns one global ledger map |
| Nonce | Recent blockhash or durable nonce | These mechanisms are not equivalent |
| State commitment | Runtime/account state hashing | Your model is highly simplified |
| Ordered block execution | Runtime transaction processing | Solana schedules around account access and runtime constraints |