Skip to content

Repository files navigation

rustchain

CI Rust libp2p License


Rustchain is a proof-of-work blockchain node written in Rust. It mines, gossips with peers over libp2p, signs transactions with ed25519 keys, keeps its chain on disk, exposes a REST API, and ships a dashboard inside the same binary — no separate frontend to build, no external services to run.

It is a complete, working chain you can read end to end in an afternoon: every consensus rule lives in one file, and every rule has a test that tries to break it.

cargo run --release -- node --mine     # → http://127.0.0.1:8080

Contents


What it does

Proof of work SHA-256 with bit-level difficulty. A block is valid when its hash carries at least difficulty leading zero bits, so the target moves in 2× steps instead of 16× ones.
Difficulty retargeting Every 10 blocks the chain compares how long the window really took against the target and steps difficulty up or down, with a floor it never drops below.
ed25519 wallets Keys are generated locally, addresses are base58 with a checksum, and a mistyped address is rejected before it can reach a block.
Signed transactions Every transfer carries an account nonce, a fee and a signature over a canonical payload. Replays, forged senders and edited amounts all fail validation.
Mempool Fee-ordered, nonce-aware, capacity-bounded. Transactions invalidated by a new block are pruned automatically.
Merkle roots Each block commits to its transactions through a merkle root over transaction ids.
Fork choice The chain with the most accumulated work wins, not the longest one. Ties keep the local chain so nodes do not flap.
P2P libp2p with TCP + Noise + Yamux, gossipsub for blocks and transactions, mDNS for LAN discovery, DNS multiaddrs so containers can address each other by name, plus identify and ping.
Chain sync A node that falls behind asks the network for a full chain, validates it from genesis, and adopts it only if it is heavier.
Persistence The chain is written atomically (temp file + rename), so a crash mid-write cannot corrupt it. The libp2p identity survives restarts.
REST API 15 endpoints for blocks, balances, transactions, peers, wallets and mining control.
Dashboard A live console served by the node itself, embedded in the binary at compile time.
Metrics Prometheus exposition at /metrics.

Screenshots

The dashboard

Served straight from the node at http://127.0.0.1:8080. The tip hash shows its earned zero prefix in gold, and the ruler underneath compares the bits found against the target the miner had to beat.

Rustchain dashboard showing the chain tip, hashrate, recent blocks and the mempool

Further down: signed transfers waiting in the mempool with consecutive nonces, local wallets with their balances, and the connected peers.

Full dashboard including mempool, wallets and peers

A node joining the network

A second node starts, dials the seed, discovers it is behind, syncs, and gets to work.

Node startup log showing peer connection, chain sync and mining

The CLI

rustchain CLI creating a wallet, sending coins and inspecting the chain

Quick start

Requires Rust 1.75 or newer.

git clone https://github.com/ismoilovdevml/blockchain.git
cd blockchain
cargo build --release

Start a node with mining on:

./target/release/rustchain node --mine
▄▄▄ rustchain v1.0.0
─────────────────────────────────────────────────────────────
node       12D3KooWGcE1YakWgekErDKX88n1mXRyQAyrTH6Pj2B1CYiLjvnq
miner      rc6PGE1FwncUHyrXRT5ao65AX4JCPYov3i5
data dir   ./data
p2p port   30333
dashboard  http://127.0.0.1:8080
mining     on
─────────────────────────────────────────────────────────────

Open http://127.0.0.1:8080. The node creates a miner wallet on first run and pays every block reward into it.

Send some coins:

./target/release/rustchain wallet new bob
./target/release/rustchain send --from miner --to <bob's address> --amount 12.5 --fee 0.01
./target/release/rustchain balance <bob's address>

Run a network

Two nodes on one machine. They find each other over mDNS; the explicit --peer just removes the wait.

# terminal 1
rustchain node --data-dir ./data-1 --port 30333 --api-port 8080 --mine --miner-wallet alice

# terminal 2
rustchain node --data-dir ./data-2 --port 30334 --api-port 8081 --mine --miner-wallet bob \
  --peer /ip4/127.0.0.1/tcp/30333

Both dashboards converge on the same tip. Stop one node, let the other mine ahead, then start it again: it detects the gap, requests a chain, validates it from genesis and reorgs onto the heavier one.

rustchain status --api http://127.0.0.1:8080
rustchain status --api http://127.0.0.1:8081   # same tip hash

Docker

docker compose up --build

Three mining nodes on one bridge network, with dashboards at http://localhost:8081, http://localhost:8082 and http://localhost:8083. Each node keeps its data in a named volume, so a restart resumes the chain instead of starting over.

CLI

rustchain node      Run a node: mine, gossip with peers and serve the API
rustchain wallet    Create and inspect wallets (new, list, show, import)
rustchain send      Sign a transfer and submit it to a node
rustchain balance   Look up an address balance
rustchain status    Print a running node's status
rustchain blocks    Print recent blocks

Node flags:

Flag Env Default Meaning
--data-dir RUSTCHAIN_DATA_DIR ./data Blocks, wallets and the node key
--port RUSTCHAIN_P2P_PORT 30333 libp2p TCP port
--api-port RUSTCHAIN_API_PORT 8080 REST API and dashboard
--api-bind RUSTCHAIN_API_BIND 127.0.0.1 API bind address
--miner-wallet RUSTCHAIN_MINER_WALLET miner Wallet that receives rewards
--mine RUSTCHAIN_MINE off Start with mining on
--peer RUSTCHAIN_PEERS Bootstrap multiaddr, repeatable
--no-mdns Disable LAN discovery
--difficulty 16 Genesis difficulty in bits
--block-time 15 Target seconds per block

Log verbosity comes from RUSTCHAIN_LOG, e.g. RUSTCHAIN_LOG=rustchain=debug.

REST API

Base URL http://127.0.0.1:8080.

Method Path Description
GET / Dashboard
GET /api/health Liveness
GET /api/status Height, tip, difficulty, hashrate, peers, supply, counters
GET /api/chain?limit=&offset= Blocks, newest first
GET /api/blocks/{height|hash} One block
GET /api/mempool Pending transactions, fee-ordered
POST /api/transactions Submit a signed transaction
GET /api/transactions/{id} Status: confirmed (with depth) or pending
GET /api/balance/{address} Balance, nonce and next usable nonce
GET /api/accounts Richest 50 accounts
GET /api/peers Connected peers
GET /api/wallets Local wallets with balances
POST /api/wallets Create a wallet — {"name":"bob"}
POST /api/transfer Sign with a local wallet and submit
POST /api/mining Toggle mining — {"enabled":true}
GET /metrics Prometheus metrics
curl -s localhost:8080/api/status | jq

curl -s -X POST localhost:8080/api/transfer \
  -H 'content-type: application/json' \
  -d '{"from":"miner","to":"rcP8pog8DhpXhtcWhfroJSC1Ci3taSmvyBN","amount":"12.5","fee":"0.01"}'

Errors come back as {"error": "..."} with a matching HTTP status.

How it works

flowchart TB
    subgraph node["rustchain node"]
        direction TB
        api["REST API + dashboard<br/>axum"]
        pool["mempool"]
        miner["mining loop<br/>spawn_blocking"]
        chain["chain<br/>consensus + account state"]
        store["storage<br/>atomic snapshots"]
        p2p["p2p<br/>gossipsub · mDNS · identify"]

        api -->|submit tx| pool
        api -->|read| chain
        pool -->|fee-ordered batch| miner
        miner -->|mined block| chain
        chain --> store
        p2p -->|blocks · txs · chain syncs| chain
        chain -->|broadcast| p2p
    end

    peers["other nodes"] <-->|gossip| p2p
    cli["rustchain CLI"] -->|HTTP| api
Loading

A block

{
  "index": 69,
  "timestamp": 1785519812,
  "previous_hash": "000000601175db559a617341e6f58f9b…",
  "merkle_root": "8f2c…",          // over the transaction ids
  "difficulty": 25,                 // required leading zero bits
  "nonce": 24307833,                // what the miner searched for
  "hash": "00000018bc95be992f08776f…",
  "miner": "rc25Z3xgkGd91AEwV9AMoGhzS8xUp7565uw",
  "transactions": [ /* coinbase first, then transfers */ ]
}

The hash covers every header field including the nonce and the merkle root, so changing anything — a timestamp, a recipient, the order of transactions — invalidates the proof of work.

Mining

mine() increments a nonce and hashes the header until enough leading zero bits appear. It runs on a blocking thread and polls a shared cancel flag every 8192 attempts, so when a peer wins the race for that height the local miner drops its candidate immediately instead of finishing work that is already worthless.

Difficulty

Difficulty is a bit count, not a hex-digit count. Every retarget_interval blocks the chain compares how long the window actually took against target_block_time × interval and steps the value:

Window took Change
< ¼ of target +2 bits
< ½ of target +1 bit
> 2× target −1 bit
> 4× target −2 bits

Never below min_difficulty. Every node computes the expected difficulty independently and rejects a block that claims a different one.

Transactions

A transfer is signed over a canonical payload of from, to, amount, fee, nonce, timestamp, public_key, and its id is the SHA-256 of that same payload. Validation checks, in order:

  1. the id matches the payload — no silent edits
  2. the public key derives the sender address — no impersonation
  3. the signature verifies
  4. the nonce is exactly what the account owes — no replays
  5. the balance covers amount + fee

The block reward is a coinbase transaction that must come first, must be the only one, and must pay exactly subsidy + fees. The subsidy halves every halving_interval blocks.

Because nonces are strict, sending several transfers back to back has to account for the mempool too — that is what next_nonce does, and why four transfers fired in one second land as nonces 4, 5, 6, 7 instead of colliding on one.

Fork choice

When a chain arrives from a peer it is replayed from genesis: every block, every signature, every balance. Only then is its accumulated work — the sum of 2^difficulty — compared against the local chain's. Strictly heavier wins; equal work keeps what we already have.

That distinction matters: a long chain of easy blocks must not beat a short chain of hard ones.

Storage

data/chain.json holds the blocks and the consensus parameters that produced them. Writes land in a temp file that is then renamed over the target, so the file on disk is always a complete snapshot. On startup the stored chain is fully re-validated, and a snapshot whose parameters differ from the ones requested is refused rather than silently reinterpreted — different parameters mean a different network.

Configuration

Consensus defaults, all in src/chain.rs:

Parameter Default
initial_difficulty 16 bits genesis difficulty
min_difficulty 8 bits retarget floor
target_block_time_secs 15 desired spacing
retarget_interval 10 blocks per retarget window
block_reward 50 RC base subsidy
halving_interval 1000 blocks between halvings
max_transactions_per_block 512 excluding the coinbase
max_future_drift_secs 120 timestamp tolerance

1 RC = 1,000,000 base units (6 decimals).

Project layout

src/
├── main.rs          CLI entry point (clap)
├── lib.rs           library root
├── crypto.rs        hashing, difficulty, address derivation
├── wallet.rs        ed25519 keys and the keystore
├── transaction.rs   signed transfers
├── block.rs         block layout, merkle roots, the miner
├── chain.rs         consensus rules, account state, fork choice
├── mempool.rs       pending transactions
├── storage.rs       atomic persistence
├── node.rs          shared runtime state, mining loop
├── p2p.rs           libp2p behaviour and event loop
├── api.rs           REST API
├── rpc.rs           the CLI's HTTP client
└── web/             dashboard, embedded at compile time
tests/
└── integration.rs   live API, chain sync, reorg

Testing

cargo test                                    # 49 tests
cargo clippy --all-targets -- -D warnings
cargo fmt --all -- --check

The suite is written to attack the chain rather than to confirm it works: tampered blocks, inflated coinbases, forged senders, replayed transactions, equal-work forks, chains from a different network, difficulty floors. The integration tests boot a real node behind its real HTTP API, mine, spend, and watch a shorter node adopt a heavier chain.

What this is not

This is a learning chain and a working one, but it is not a production cryptocurrency. The limits, stated plainly:

  • Keys are stored unencrypted (0600 files). Fine for a testnet, not for value.
  • No fork storage. A node keeps one chain; competing forks are resolved by requesting a full chain rather than by keeping side branches.
  • Chain sync sends the whole chain, capped at 8 MB per gossip message. Fine for a demo network, not for a chain with millions of blocks.
  • No transaction expiry beyond the mempool's capacity limit.
  • The API has no authentication. Keep it on localhost or put a reverse proxy with auth in front of it — --api-bind 0.0.0.0 exposes wallet operations.
  • /api/transfer signs with keys held by the node, which a real node would never do. It exists so the dashboard and demos work; the CLI's send signs locally and submits an already-signed transaction, which is the honest path.

License

MIT — see LICENSE.

Built by @ismoilovdevml with tokio, libp2p, axum, serde, ed25519-dalek and clap.

About

Blockchain

Topics

Resources

Stars

6 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages