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- What it does
- Screenshots
- Quick start
- Run a network
- Docker
- CLI
- REST API
- How it works
- Configuration
- Project layout
- Testing
- What this is not
- License
| 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. |
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.
Further down: signed transfers waiting in the mempool with consecutive nonces, local wallets with their balances, and the connected peers.
A second node starts, dials the seed, discovers it is behind, syncs, and gets to work.
Requires Rust 1.75 or newer.
git clone https://github.com/ismoilovdevml/blockchain.git
cd blockchain
cargo build --releaseStart 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>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/30333Both 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 hashdocker compose up --buildThree 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.
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.
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.
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
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.
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 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.
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:
- the id matches the payload — no silent edits
- the public key derives the sender address — no impersonation
- the signature verifies
- the nonce is exactly what the account owes — no replays
- 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.
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.
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.
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).
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
cargo test # 49 tests
cargo clippy --all-targets -- -D warnings
cargo fmt --all -- --checkThe 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.
This is a learning chain and a working one, but it is not a production cryptocurrency. The limits, stated plainly:
- Keys are stored unencrypted (
0600files). 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.0exposes wallet operations. /api/transfersigns with keys held by the node, which a real node would never do. It exists so the dashboard and demos work; the CLI'ssendsigns locally and submits an already-signed transaction, which is the honest path.
MIT — see LICENSE.
Built by @ismoilovdevml with tokio, libp2p, axum, serde, ed25519-dalek and clap.





{ "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 */ ] }