Initial setup: Rust workspace with core lib + axum node
- Workspace: core/ (blockchain library) + node/ (REST API) - Core: block, chain, wallet, transaction, mining, persistence, state - Node: axum 0.8 REST API with full endpoint set - SHA-256 hashing, Ed25519 signatures, account-based model - Unit tests for all core modules
This commit is contained in:
commit
64ea897cdc
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/target
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
blockchain_data/
|
||||||
9
Cargo.toml
Normal file
9
Cargo.toml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["core", "node"]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Alex"]
|
||||||
|
license = "MIT"
|
||||||
133
README.md
Normal file
133
README.md
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
# blockchain-core
|
||||||
|
|
||||||
|
Rust workspace implementing a minimal blockchain from scratch. Educational project demonstrating core blockchain concepts with production-quality Rust code.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
blockchain-core/
|
||||||
|
├── core/ # Library crate - blockchain primitives
|
||||||
|
│ └── src/
|
||||||
|
│ ├── lib.rs # Re-exports
|
||||||
|
│ ├── block.rs # Block + BlockHeader, SHA-256 hashing
|
||||||
|
│ ├── transaction.rs # Transaction, signing, verification
|
||||||
|
│ ├── chain.rs # Blockchain struct, validation, state
|
||||||
|
│ ├── wallet.rs # Ed25519 keypair, address generation
|
||||||
|
│ ├── mining.rs # Proof of Work consensus
|
||||||
|
│ ├── state.rs # AccountState (balance + nonce)
|
||||||
|
│ ├── error.rs # BlockchainError enum (thiserror)
|
||||||
|
│ ├── config.rs # Difficulty, block reward constants
|
||||||
|
│ └── persistence.rs # JSON save/load
|
||||||
|
├── node/ # Binary crate - REST API server
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.rs # Entry point: load chain, start axum
|
||||||
|
│ ├── state.rs # AppState: Arc<RwLock<Blockchain>>
|
||||||
|
│ └── api/
|
||||||
|
│ ├── mod.rs # Router setup + CORS
|
||||||
|
│ ├── blocks.rs # GET /api/blocks, GET /api/blocks/:hash
|
||||||
|
│ ├── transactions.rs # POST /api/transactions
|
||||||
|
│ ├── wallets.rs # POST /api/wallets, GET balance
|
||||||
|
│ ├── mining.rs # POST /api/mine
|
||||||
|
│ ├── chain.rs # GET /api/chain/info
|
||||||
|
│ └── errors.rs # API error responses
|
||||||
|
└── tests/ # Integration tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Transaction model | Account-based | Simpler than UTXO, `HashMap<Address, Balance>` |
|
||||||
|
| Hashing | SHA-256 (`sha2`) | Bitcoin standard, well documented |
|
||||||
|
| Signatures | Ed25519 (`ed25519-dalek`) | Modern, fast, excellent Rust support |
|
||||||
|
| API framework | axum 0.8 | Tokio-native, no macros, clean API |
|
||||||
|
| Persistence | JSON files | No DB setup needed, human-readable |
|
||||||
|
| Amounts | `u64` (smallest unit) | No floats, like satoshis in Bitcoin |
|
||||||
|
|
||||||
|
## Data Structures
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct Block {
|
||||||
|
header: BlockHeader, // index, timestamp, prev_hash, hash, nonce, difficulty
|
||||||
|
transactions: Vec<Transaction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Transaction {
|
||||||
|
id: String, // UUID
|
||||||
|
from: String, // Hex public key (or "coinbase")
|
||||||
|
to: String, // Hex public key
|
||||||
|
amount: u64, // Smallest unit
|
||||||
|
nonce: u64, // Anti-replay protection
|
||||||
|
timestamp: DateTime<Utc>,
|
||||||
|
signature: String, // Ed25519 signature
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Wallet {
|
||||||
|
address: String, // Hex-encoded public key
|
||||||
|
signing_key_bytes: Option<Vec<u8>>, // Private key (never in API)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Blockchain {
|
||||||
|
blocks: Vec<Block>,
|
||||||
|
pending_transactions: Vec<Transaction>,
|
||||||
|
accounts: HashMap<String, AccountState>,
|
||||||
|
difficulty: u32,
|
||||||
|
block_reward: u64,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `GET` | `/api/blocks` | List blocks (paginated) |
|
||||||
|
| `GET` | `/api/blocks/:hash` | Get block by hash |
|
||||||
|
| `POST` | `/api/transactions` | Submit signed transaction |
|
||||||
|
| `GET` | `/api/transactions/pending` | View pending transaction pool |
|
||||||
|
| `POST` | `/api/wallets` | Generate new wallet keypair |
|
||||||
|
| `GET` | `/api/wallets/:address/balance` | Get balance and nonce |
|
||||||
|
| `POST` | `/api/mine` | Mine pending transactions into block |
|
||||||
|
| `GET` | `/api/mining/status` | Difficulty, reward, pending count |
|
||||||
|
| `GET` | `/api/chain/info` | Chain length, difficulty, latest hash |
|
||||||
|
| `POST` | `/api/chain/validate` | Full chain validation |
|
||||||
|
| `GET` | `/api/health` | Node health check |
|
||||||
|
|
||||||
|
## Rust Concepts Demonstrated
|
||||||
|
|
||||||
|
- **Ownership & Move**: blocks pushed into chain `Vec`
|
||||||
|
- **Borrowing**: wallet signing via `&self`
|
||||||
|
- **Enums + Pattern Matching**: `BlockchainError`, API responses
|
||||||
|
- **Traits (derive)**: `Serialize`, `Deserialize`, `Debug`, `Clone`
|
||||||
|
- **Error handling**: `Result<T, E>` with `thiserror` + `?` operator
|
||||||
|
- **Collections**: `Vec<Block>`, `HashMap<String, AccountState>`
|
||||||
|
- **Iterators**: chain validation, transaction filtering
|
||||||
|
- **Async/await**: axum handlers, tokio runtime
|
||||||
|
- **Arc<RwLock<T>>**: shared state across concurrent API handlers
|
||||||
|
- **Module system**: workspace with multiple crates
|
||||||
|
- **Tests**: unit + integration
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build everything
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Start the node (port 3000)
|
||||||
|
cargo run -p blockchain-node
|
||||||
|
|
||||||
|
# In another terminal, test with curl
|
||||||
|
curl http://localhost:3000/api/health
|
||||||
|
curl http://localhost:3000/api/chain/info
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Repos
|
||||||
|
|
||||||
|
- [blockchain-cli](../blockchain-cli) - Rust CLI tool (communicates via HTTP)
|
||||||
|
- [blockchain-flutter](../blockchain-flutter) - Flutter mobile/web app (communicates via HTTP)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
18
core/Cargo.toml
Normal file
18
core/Cargo.toml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "blockchain-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "Core blockchain library: blocks, chain, wallets, transactions, mining"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
sha2 = "0.10"
|
||||||
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
|
rand = "0.8"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
hex = "0.4"
|
||||||
|
thiserror = "2"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
94
core/src/block.rs
Normal file
94
core/src/block.rs
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::transaction::Transaction;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BlockHeader {
|
||||||
|
pub index: u64,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
pub previous_hash: String,
|
||||||
|
pub hash: String,
|
||||||
|
pub nonce: u64,
|
||||||
|
pub difficulty: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Block {
|
||||||
|
pub header: BlockHeader,
|
||||||
|
pub transactions: Vec<Transaction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Block {
|
||||||
|
/// Create a new block (hash is computed, nonce is 0 until mined).
|
||||||
|
pub fn new(index: u64, previous_hash: String, transactions: Vec<Transaction>, difficulty: u32) -> Self {
|
||||||
|
let mut block = Block {
|
||||||
|
header: BlockHeader {
|
||||||
|
index,
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
previous_hash,
|
||||||
|
hash: String::new(),
|
||||||
|
nonce: 0,
|
||||||
|
difficulty,
|
||||||
|
},
|
||||||
|
transactions,
|
||||||
|
};
|
||||||
|
block.header.hash = block.calculate_hash();
|
||||||
|
block
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the genesis block.
|
||||||
|
pub fn genesis() -> Self {
|
||||||
|
Block::new(0, crate::config::GENESIS_PREV_HASH.to_string(), vec![], crate::config::INITIAL_DIFFICULTY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate SHA-256 hash of the block.
|
||||||
|
pub fn calculate_hash(&self) -> String {
|
||||||
|
let data = format!(
|
||||||
|
"{}{}{}{}{}",
|
||||||
|
self.header.index,
|
||||||
|
self.header.timestamp.timestamp_millis(),
|
||||||
|
self.header.previous_hash,
|
||||||
|
self.header.nonce,
|
||||||
|
serde_json::to_string(&self.transactions).unwrap_or_default(),
|
||||||
|
);
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(data.as_bytes());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the block hash satisfies the difficulty target.
|
||||||
|
pub fn hash_meets_difficulty(&self) -> bool {
|
||||||
|
let prefix = "0".repeat(self.header.difficulty as usize);
|
||||||
|
self.header.hash.starts_with(&prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_genesis_block() {
|
||||||
|
let genesis = Block::genesis();
|
||||||
|
assert_eq!(genesis.header.index, 0);
|
||||||
|
assert!(genesis.transactions.is_empty());
|
||||||
|
assert!(!genesis.header.hash.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calculate_hash_deterministic() {
|
||||||
|
let block = Block::genesis();
|
||||||
|
let hash1 = block.calculate_hash();
|
||||||
|
let hash2 = block.calculate_hash();
|
||||||
|
assert_eq!(hash1, hash2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hash_is_hex_64_chars() {
|
||||||
|
let block = Block::genesis();
|
||||||
|
assert_eq!(block.header.hash.len(), 64);
|
||||||
|
assert!(block.header.hash.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
}
|
||||||
255
core/src/chain.rs
Normal file
255
core/src/chain.rs
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::block::Block;
|
||||||
|
use crate::config::{BLOCK_REWARD, INITIAL_DIFFICULTY, MAX_TRANSACTIONS_PER_BLOCK};
|
||||||
|
use crate::error::{BlockchainError, Result};
|
||||||
|
use crate::mining::mine_block;
|
||||||
|
use crate::state::AccountState;
|
||||||
|
use crate::transaction::Transaction;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Blockchain {
|
||||||
|
pub blocks: Vec<Block>,
|
||||||
|
pub pending_transactions: Vec<Transaction>,
|
||||||
|
pub accounts: HashMap<String, AccountState>,
|
||||||
|
pub difficulty: u32,
|
||||||
|
pub block_reward: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Blockchain {
|
||||||
|
/// Create a new blockchain with the genesis block.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let genesis = mine_block(Block::genesis());
|
||||||
|
Blockchain {
|
||||||
|
blocks: vec![genesis],
|
||||||
|
pending_transactions: Vec::new(),
|
||||||
|
accounts: HashMap::new(),
|
||||||
|
difficulty: INITIAL_DIFFICULTY,
|
||||||
|
block_reward: BLOCK_REWARD,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the latest block in the chain.
|
||||||
|
pub fn latest_block(&self) -> &Block {
|
||||||
|
self.blocks.last().expect("chain always has at least genesis block")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get account state, creating a default if not found.
|
||||||
|
pub fn get_account(&self, address: &str) -> AccountState {
|
||||||
|
self.accounts.get(address).cloned().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a signed transaction to the pending pool.
|
||||||
|
pub fn add_transaction(&mut self, tx: Transaction) -> Result<()> {
|
||||||
|
if tx.is_coinbase() {
|
||||||
|
return Err(BlockchainError::InvalidTransaction(
|
||||||
|
"cannot manually add coinbase transaction".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
tx.verify_signature()?;
|
||||||
|
|
||||||
|
// Check sender balance
|
||||||
|
let account = self.get_account(&tx.from);
|
||||||
|
if account.balance < tx.amount {
|
||||||
|
return Err(BlockchainError::InsufficientBalance {
|
||||||
|
have: account.balance,
|
||||||
|
need: tx.amount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check nonce
|
||||||
|
if tx.nonce != account.nonce {
|
||||||
|
return Err(BlockchainError::InvalidNonce {
|
||||||
|
expected: account.nonce,
|
||||||
|
got: tx.nonce,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.pending_transactions.push(tx);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mine pending transactions into a new block.
|
||||||
|
pub fn mine_pending(&mut self, miner_address: &str) -> Result<Block> {
|
||||||
|
// Take up to MAX_TRANSACTIONS_PER_BLOCK from pending
|
||||||
|
let tx_count = self.pending_transactions.len().min(MAX_TRANSACTIONS_PER_BLOCK);
|
||||||
|
let mut transactions: Vec<Transaction> = self.pending_transactions.drain(..tx_count).collect();
|
||||||
|
|
||||||
|
// Add coinbase reward
|
||||||
|
let coinbase = Transaction::coinbase(miner_address.to_string(), self.block_reward);
|
||||||
|
transactions.insert(0, coinbase);
|
||||||
|
|
||||||
|
let previous_hash = self.latest_block().header.hash.clone();
|
||||||
|
let index = self.blocks.len() as u64;
|
||||||
|
|
||||||
|
let block = Block::new(index, previous_hash, transactions, self.difficulty);
|
||||||
|
let mined_block = mine_block(block);
|
||||||
|
|
||||||
|
// Apply transactions to account state
|
||||||
|
for tx in &mined_block.transactions {
|
||||||
|
self.apply_transaction(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.blocks.push(mined_block.clone());
|
||||||
|
Ok(mined_block)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a transaction to the account state.
|
||||||
|
fn apply_transaction(&mut self, tx: &Transaction) {
|
||||||
|
if tx.is_coinbase() {
|
||||||
|
let account = self.accounts.entry(tx.to.clone()).or_default();
|
||||||
|
account.balance += tx.amount;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debit sender
|
||||||
|
let sender = self.accounts.entry(tx.from.clone()).or_default();
|
||||||
|
sender.balance = sender.balance.saturating_sub(tx.amount);
|
||||||
|
sender.nonce += 1;
|
||||||
|
|
||||||
|
// Credit receiver
|
||||||
|
let receiver = self.accounts.entry(tx.to.clone()).or_default();
|
||||||
|
receiver.balance += tx.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the entire chain integrity.
|
||||||
|
pub fn is_valid(&self) -> Result<()> {
|
||||||
|
for i in 1..self.blocks.len() {
|
||||||
|
let current = &self.blocks[i];
|
||||||
|
let previous = &self.blocks[i - 1];
|
||||||
|
|
||||||
|
// Check hash links
|
||||||
|
if current.header.previous_hash != previous.header.hash {
|
||||||
|
return Err(BlockchainError::InvalidChain(format!(
|
||||||
|
"block {} previous_hash mismatch",
|
||||||
|
current.header.index
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify hash
|
||||||
|
let recalculated = current.calculate_hash();
|
||||||
|
if current.header.hash != recalculated {
|
||||||
|
return Err(BlockchainError::InvalidChain(format!(
|
||||||
|
"block {} hash mismatch",
|
||||||
|
current.header.index
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check difficulty
|
||||||
|
if !current.hash_meets_difficulty() {
|
||||||
|
return Err(BlockchainError::InvalidChain(format!(
|
||||||
|
"block {} does not meet difficulty",
|
||||||
|
current.header.index
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check index sequence
|
||||||
|
if current.header.index != previous.header.index + 1 {
|
||||||
|
return Err(BlockchainError::InvalidChain(format!(
|
||||||
|
"block index not sequential at {}",
|
||||||
|
current.header.index
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a block by its hash.
|
||||||
|
pub fn get_block_by_hash(&self, hash: &str) -> Option<&Block> {
|
||||||
|
self.blocks.iter().find(|b| b.header.hash == hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chain length.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.blocks.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.blocks.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Blockchain {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::wallet::Wallet;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_chain_has_genesis() {
|
||||||
|
let chain = Blockchain::new();
|
||||||
|
assert_eq!(chain.len(), 1);
|
||||||
|
assert_eq!(chain.blocks[0].header.index, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mine_block() {
|
||||||
|
let mut chain = Blockchain::new();
|
||||||
|
let block = chain.mine_pending("miner_addr").unwrap();
|
||||||
|
assert_eq!(chain.len(), 2);
|
||||||
|
assert_eq!(block.header.index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mining_reward() {
|
||||||
|
let mut chain = Blockchain::new();
|
||||||
|
chain.mine_pending("miner_addr").unwrap();
|
||||||
|
let account = chain.get_account("miner_addr");
|
||||||
|
assert_eq!(account.balance, BLOCK_REWARD);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_chain_validation() {
|
||||||
|
let mut chain = Blockchain::new();
|
||||||
|
chain.mine_pending("miner").unwrap();
|
||||||
|
chain.mine_pending("miner").unwrap();
|
||||||
|
assert!(chain.is_valid().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_transaction_flow() {
|
||||||
|
let sender = Wallet::new();
|
||||||
|
let receiver = Wallet::new();
|
||||||
|
let mut chain = Blockchain::new();
|
||||||
|
|
||||||
|
// Mine to give sender some coins
|
||||||
|
chain.mine_pending(&sender.address).unwrap();
|
||||||
|
|
||||||
|
// Create and sign transaction
|
||||||
|
let mut tx = Transaction::new(sender.address.clone(), receiver.address.clone(), 1000, 0);
|
||||||
|
let signing_key = sender.signing_key().unwrap();
|
||||||
|
tx.sign(&signing_key).unwrap();
|
||||||
|
|
||||||
|
// Add to pending
|
||||||
|
chain.add_transaction(tx).unwrap();
|
||||||
|
|
||||||
|
// Mine block with transaction
|
||||||
|
chain.mine_pending(&sender.address).unwrap();
|
||||||
|
|
||||||
|
let receiver_account = chain.get_account(&receiver.address);
|
||||||
|
assert_eq!(receiver_account.balance, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_insufficient_balance() {
|
||||||
|
let sender = Wallet::new();
|
||||||
|
let mut chain = Blockchain::new();
|
||||||
|
|
||||||
|
let mut tx = Transaction::new(sender.address.clone(), "receiver".to_string(), 1000, 0);
|
||||||
|
let signing_key = sender.signing_key().unwrap();
|
||||||
|
tx.sign(&signing_key).unwrap();
|
||||||
|
|
||||||
|
let result = chain.add_transaction(tx);
|
||||||
|
assert!(matches!(result, Err(BlockchainError::InsufficientBalance { .. })));
|
||||||
|
}
|
||||||
|
}
|
||||||
18
core/src/config.rs
Normal file
18
core/src/config.rs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/// Initial mining difficulty (number of leading zero bits required).
|
||||||
|
pub const INITIAL_DIFFICULTY: u32 = 2;
|
||||||
|
|
||||||
|
/// Block reward in smallest units (like satoshis).
|
||||||
|
/// 50 coins = 50_000_000 smallest units.
|
||||||
|
pub const BLOCK_REWARD: u64 = 50_000_000;
|
||||||
|
|
||||||
|
/// Maximum transactions per block.
|
||||||
|
pub const MAX_TRANSACTIONS_PER_BLOCK: usize = 100;
|
||||||
|
|
||||||
|
/// Genesis block previous hash.
|
||||||
|
pub const GENESIS_PREV_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||||
|
|
||||||
|
/// Data directory for JSON persistence.
|
||||||
|
pub const DATA_DIR: &str = "blockchain_data";
|
||||||
|
|
||||||
|
/// Blockchain state filename.
|
||||||
|
pub const CHAIN_FILE: &str = "chain.json";
|
||||||
33
core/src/error.rs
Normal file
33
core/src/error.rs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum BlockchainError {
|
||||||
|
#[error("invalid block: {0}")]
|
||||||
|
InvalidBlock(String),
|
||||||
|
|
||||||
|
#[error("invalid transaction: {0}")]
|
||||||
|
InvalidTransaction(String),
|
||||||
|
|
||||||
|
#[error("invalid chain: {0}")]
|
||||||
|
InvalidChain(String),
|
||||||
|
|
||||||
|
#[error("insufficient balance: have {have}, need {need}")]
|
||||||
|
InsufficientBalance { have: u64, need: u64 },
|
||||||
|
|
||||||
|
#[error("invalid nonce: expected {expected}, got {got}")]
|
||||||
|
InvalidNonce { expected: u64, got: u64 },
|
||||||
|
|
||||||
|
#[error("invalid signature")]
|
||||||
|
InvalidSignature,
|
||||||
|
|
||||||
|
#[error("wallet error: {0}")]
|
||||||
|
WalletError(String),
|
||||||
|
|
||||||
|
#[error("persistence error: {0}")]
|
||||||
|
PersistenceError(String),
|
||||||
|
|
||||||
|
#[error("mining error: {0}")]
|
||||||
|
MiningError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, BlockchainError>;
|
||||||
16
core/src/lib.rs
Normal file
16
core/src/lib.rs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
pub mod block;
|
||||||
|
pub mod chain;
|
||||||
|
pub mod config;
|
||||||
|
pub mod error;
|
||||||
|
pub mod mining;
|
||||||
|
pub mod persistence;
|
||||||
|
pub mod state;
|
||||||
|
pub mod transaction;
|
||||||
|
pub mod wallet;
|
||||||
|
|
||||||
|
pub use block::{Block, BlockHeader};
|
||||||
|
pub use chain::Blockchain;
|
||||||
|
pub use error::{BlockchainError, Result};
|
||||||
|
pub use state::AccountState;
|
||||||
|
pub use transaction::Transaction;
|
||||||
|
pub use wallet::Wallet;
|
||||||
37
core/src/mining.rs
Normal file
37
core/src/mining.rs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
use crate::block::Block;
|
||||||
|
|
||||||
|
/// Mine a block by finding a nonce that produces a hash meeting the difficulty target.
|
||||||
|
/// Returns the mined block with valid hash and nonce.
|
||||||
|
pub fn mine_block(mut block: Block) -> Block {
|
||||||
|
let target_prefix = "0".repeat(block.header.difficulty as usize);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
block.header.hash = block.calculate_hash();
|
||||||
|
if block.header.hash.starts_with(&target_prefix) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
block.header.nonce += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
block
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::INITIAL_DIFFICULTY;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mine_block_meets_difficulty() {
|
||||||
|
let block = Block::new(1, "prev_hash".to_string(), vec![], INITIAL_DIFFICULTY);
|
||||||
|
let mined = mine_block(block);
|
||||||
|
assert!(mined.hash_meets_difficulty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mined_block_hash_starts_with_zeros() {
|
||||||
|
let block = Block::new(1, "abc".to_string(), vec![], 2);
|
||||||
|
let mined = mine_block(block);
|
||||||
|
assert!(mined.header.hash.starts_with("00"));
|
||||||
|
}
|
||||||
|
}
|
||||||
63
core/src/persistence.rs
Normal file
63
core/src/persistence.rs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::config::{CHAIN_FILE, DATA_DIR};
|
||||||
|
use crate::error::{BlockchainError, Result};
|
||||||
|
|
||||||
|
/// Save data as JSON to the default data directory.
|
||||||
|
pub fn save_json<T: serde::Serialize>(data: &T) -> Result<()> {
|
||||||
|
let dir = Path::new(DATA_DIR);
|
||||||
|
fs::create_dir_all(dir).map_err(|e| BlockchainError::PersistenceError(e.to_string()))?;
|
||||||
|
|
||||||
|
let path = dir.join(CHAIN_FILE);
|
||||||
|
let json = serde_json::to_string_pretty(data)
|
||||||
|
.map_err(|e| BlockchainError::PersistenceError(e.to_string()))?;
|
||||||
|
|
||||||
|
fs::write(&path, json).map_err(|e| BlockchainError::PersistenceError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load data from JSON in the default data directory.
|
||||||
|
pub fn load_json<T: serde::de::DeserializeOwned>() -> Result<Option<T>> {
|
||||||
|
let path = Path::new(DATA_DIR).join(CHAIN_FILE);
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = fs::read_to_string(&path).map_err(|e| BlockchainError::PersistenceError(e.to_string()))?;
|
||||||
|
|
||||||
|
let data =
|
||||||
|
serde_json::from_str(&json).map_err(|e| BlockchainError::PersistenceError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Some(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save data as JSON to a custom path.
|
||||||
|
pub fn save_json_to<T: serde::Serialize>(data: &T, path: &Path) -> Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| BlockchainError::PersistenceError(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(data)
|
||||||
|
.map_err(|e| BlockchainError::PersistenceError(e.to_string()))?;
|
||||||
|
|
||||||
|
fs::write(path, json).map_err(|e| BlockchainError::PersistenceError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load data from JSON at a custom path.
|
||||||
|
pub fn load_json_from<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Option<T>> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = fs::read_to_string(path).map_err(|e| BlockchainError::PersistenceError(e.to_string()))?;
|
||||||
|
|
||||||
|
let data =
|
||||||
|
serde_json::from_str(&json).map_err(|e| BlockchainError::PersistenceError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Some(data))
|
||||||
|
}
|
||||||
30
core/src/state.rs
Normal file
30
core/src/state.rs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Account state in the account-based model.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct AccountState {
|
||||||
|
pub balance: u64,
|
||||||
|
pub nonce: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_balance(balance: u64) -> Self {
|
||||||
|
AccountState { balance, nonce: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_account() {
|
||||||
|
let account = AccountState::new();
|
||||||
|
assert_eq!(account.balance, 0);
|
||||||
|
assert_eq!(account.nonce, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
134
core/src/transaction.rs
Normal file
134
core/src/transaction.rs
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::error::{BlockchainError, Result};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Transaction {
|
||||||
|
pub id: String,
|
||||||
|
pub from: String,
|
||||||
|
pub to: String,
|
||||||
|
pub amount: u64,
|
||||||
|
pub nonce: u64,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
pub signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transaction {
|
||||||
|
/// Create a new unsigned transaction.
|
||||||
|
pub fn new(from: String, to: String, amount: u64, nonce: u64) -> Self {
|
||||||
|
Transaction {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
amount,
|
||||||
|
nonce,
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
signature: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a coinbase (mining reward) transaction.
|
||||||
|
pub fn coinbase(to: String, reward: u64) -> Self {
|
||||||
|
Transaction {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
from: "coinbase".to_string(),
|
||||||
|
to,
|
||||||
|
amount: reward,
|
||||||
|
nonce: 0,
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
signature: "coinbase".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Data to sign (excludes signature and id fields).
|
||||||
|
pub fn signable_data(&self) -> Vec<u8> {
|
||||||
|
let data = format!(
|
||||||
|
"{}{}{}{}{}",
|
||||||
|
self.from, self.to, self.amount, self.nonce, self.timestamp.timestamp_millis()
|
||||||
|
);
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(data.as_bytes());
|
||||||
|
hasher.finalize().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign the transaction with a private key.
|
||||||
|
pub fn sign(&mut self, signing_key: &SigningKey) -> Result<()> {
|
||||||
|
let data = self.signable_data();
|
||||||
|
let signature = signing_key.sign(&data);
|
||||||
|
self.signature = hex::encode(signature.to_bytes());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify the transaction signature against the sender's public key.
|
||||||
|
pub fn verify_signature(&self) -> Result<()> {
|
||||||
|
if self.from == "coinbase" {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let pub_key_bytes = hex::decode(&self.from)
|
||||||
|
.map_err(|e| BlockchainError::InvalidTransaction(format!("invalid from address: {e}")))?;
|
||||||
|
|
||||||
|
let verifying_key = VerifyingKey::from_bytes(
|
||||||
|
pub_key_bytes
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| BlockchainError::InvalidTransaction("invalid public key length".into()))?,
|
||||||
|
)
|
||||||
|
.map_err(|_| BlockchainError::InvalidSignature)?;
|
||||||
|
|
||||||
|
let sig_bytes = hex::decode(&self.signature)
|
||||||
|
.map_err(|e| BlockchainError::InvalidTransaction(format!("invalid signature hex: {e}")))?;
|
||||||
|
|
||||||
|
let signature = Signature::from_bytes(
|
||||||
|
sig_bytes
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| BlockchainError::InvalidTransaction("invalid signature length".into()))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
let data = self.signable_data();
|
||||||
|
verifying_key
|
||||||
|
.verify(&data, &signature)
|
||||||
|
.map_err(|_| BlockchainError::InvalidSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_coinbase(&self) -> bool {
|
||||||
|
self.from == "coinbase"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::wallet::Wallet;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_coinbase_transaction() {
|
||||||
|
let tx = Transaction::coinbase("addr123".to_string(), 50_000_000);
|
||||||
|
assert!(tx.is_coinbase());
|
||||||
|
assert_eq!(tx.amount, 50_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sign_and_verify() {
|
||||||
|
let wallet = Wallet::new();
|
||||||
|
let mut tx = Transaction::new(wallet.address.clone(), "recipient".to_string(), 1000, 0);
|
||||||
|
let signing_key = wallet.signing_key().unwrap();
|
||||||
|
tx.sign(&signing_key).unwrap();
|
||||||
|
assert!(tx.verify_signature().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tampered_transaction_fails_verify() {
|
||||||
|
let wallet = Wallet::new();
|
||||||
|
let mut tx = Transaction::new(wallet.address.clone(), "recipient".to_string(), 1000, 0);
|
||||||
|
let signing_key = wallet.signing_key().unwrap();
|
||||||
|
tx.sign(&signing_key).unwrap();
|
||||||
|
|
||||||
|
tx.amount = 999999; // tamper
|
||||||
|
assert!(tx.verify_signature().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
84
core/src/wallet.rs
Normal file
84
core/src/wallet.rs
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::{BlockchainError, Result};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Wallet {
|
||||||
|
/// Hex-encoded Ed25519 public key (used as address).
|
||||||
|
pub address: String,
|
||||||
|
|
||||||
|
/// Private key bytes. Only present for locally-created wallets.
|
||||||
|
/// Never serialized in API responses.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub signing_key_bytes: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Wallet {
|
||||||
|
/// Generate a new wallet with a random Ed25519 keypair.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let signing_key = SigningKey::generate(&mut OsRng);
|
||||||
|
let verifying_key = signing_key.verifying_key();
|
||||||
|
|
||||||
|
Wallet {
|
||||||
|
address: hex::encode(verifying_key.as_bytes()),
|
||||||
|
signing_key_bytes: Some(signing_key.to_bytes().to_vec()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconstruct the signing key from stored bytes.
|
||||||
|
pub fn signing_key(&self) -> Result<SigningKey> {
|
||||||
|
let bytes = self
|
||||||
|
.signing_key_bytes
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| BlockchainError::WalletError("no private key available".into()))?;
|
||||||
|
|
||||||
|
let key_bytes: [u8; 32] = bytes
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| BlockchainError::WalletError("invalid key length".into()))?;
|
||||||
|
|
||||||
|
Ok(SigningKey::from_bytes(&key_bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a wallet reference (public key only, no private key).
|
||||||
|
pub fn from_address(address: String) -> Self {
|
||||||
|
Wallet {
|
||||||
|
address,
|
||||||
|
signing_key_bytes: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Wallet {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_wallet_has_address() {
|
||||||
|
let wallet = Wallet::new();
|
||||||
|
assert_eq!(wallet.address.len(), 64); // 32 bytes hex-encoded
|
||||||
|
assert!(wallet.signing_key_bytes.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_signing_key_roundtrip() {
|
||||||
|
let wallet = Wallet::new();
|
||||||
|
let key = wallet.signing_key().unwrap();
|
||||||
|
let verifying = key.verifying_key();
|
||||||
|
assert_eq!(hex::encode(verifying.as_bytes()), wallet.address);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_address_has_no_private_key() {
|
||||||
|
let wallet = Wallet::from_address("abc123".to_string());
|
||||||
|
assert!(wallet.signing_key().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
24
node/Cargo.toml
Normal file
24
node/Cargo.toml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "blockchain-node"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "REST API node for the blockchain, powered by axum"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "blockchain-node"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
blockchain-core = { path = "../core" }
|
||||||
|
axum = "0.8"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
hex = "0.4"
|
||||||
55
node/src/api/blocks.rs
Normal file
55
node/src/api/blocks.rs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::Json;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::api::errors::ApiError;
|
||||||
|
use crate::state::SharedState;
|
||||||
|
use blockchain_core::Block;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PaginationParams {
|
||||||
|
pub offset: Option<usize>,
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct BlocksResponse {
|
||||||
|
pub blocks: Vec<Block>,
|
||||||
|
pub total: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_blocks(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Query(params): Query<PaginationParams>,
|
||||||
|
) -> Result<Json<BlocksResponse>, ApiError> {
|
||||||
|
let chain = state.read().await;
|
||||||
|
let total = chain.blocks.len();
|
||||||
|
let offset = params.offset.unwrap_or(0);
|
||||||
|
let limit = params.limit.unwrap_or(10).min(100);
|
||||||
|
|
||||||
|
let blocks: Vec<Block> = chain
|
||||||
|
.blocks
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.skip(offset)
|
||||||
|
.take(limit)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(BlocksResponse { blocks, total }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_block(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Path(hash): Path<String>,
|
||||||
|
) -> Result<Json<Block>, ApiError> {
|
||||||
|
let chain = state.read().await;
|
||||||
|
chain
|
||||||
|
.get_block_by_hash(&hash)
|
||||||
|
.cloned()
|
||||||
|
.map(Json)
|
||||||
|
.ok_or(ApiError {
|
||||||
|
error: "block not found".to_string(),
|
||||||
|
code: 404,
|
||||||
|
})
|
||||||
|
}
|
||||||
48
node/src/api/chain.rs
Normal file
48
node/src/api/chain.rs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
use axum::extract::State;
|
||||||
|
use axum::Json;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::api::errors::ApiError;
|
||||||
|
use crate::state::SharedState;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ChainInfo {
|
||||||
|
pub length: usize,
|
||||||
|
pub difficulty: u32,
|
||||||
|
pub latest_hash: String,
|
||||||
|
pub block_reward: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ValidationResult {
|
||||||
|
pub valid: bool,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn chain_info(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
) -> Json<ChainInfo> {
|
||||||
|
let chain = state.read().await;
|
||||||
|
Json(ChainInfo {
|
||||||
|
length: chain.len(),
|
||||||
|
difficulty: chain.difficulty,
|
||||||
|
latest_hash: chain.latest_block().header.hash.clone(),
|
||||||
|
block_reward: chain.block_reward,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn validate_chain(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
) -> Result<Json<ValidationResult>, ApiError> {
|
||||||
|
let chain = state.read().await;
|
||||||
|
match chain.is_valid() {
|
||||||
|
Ok(()) => Ok(Json(ValidationResult {
|
||||||
|
valid: true,
|
||||||
|
error: None,
|
||||||
|
})),
|
||||||
|
Err(e) => Ok(Json(ValidationResult {
|
||||||
|
valid: false,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
40
node/src/api/errors.rs
Normal file
40
node/src/api/errors.rs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::Json;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use blockchain_core::BlockchainError;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ApiError {
|
||||||
|
pub error: String,
|
||||||
|
pub code: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let status = StatusCode::from_u16(self.code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
(status, Json(self)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BlockchainError> for ApiError {
|
||||||
|
fn from(err: BlockchainError) -> Self {
|
||||||
|
let code = match &err {
|
||||||
|
BlockchainError::InvalidTransaction(_) => 400,
|
||||||
|
BlockchainError::InsufficientBalance { .. } => 400,
|
||||||
|
BlockchainError::InvalidNonce { .. } => 400,
|
||||||
|
BlockchainError::InvalidSignature => 400,
|
||||||
|
BlockchainError::InvalidBlock(_) => 400,
|
||||||
|
BlockchainError::InvalidChain(_) => 400,
|
||||||
|
BlockchainError::WalletError(_) => 500,
|
||||||
|
BlockchainError::PersistenceError(_) => 500,
|
||||||
|
BlockchainError::MiningError(_) => 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
ApiError {
|
||||||
|
error: err.to_string(),
|
||||||
|
code,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
node/src/api/mining.rs
Normal file
41
node/src/api/mining.rs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
use axum::extract::State;
|
||||||
|
use axum::Json;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::api::errors::ApiError;
|
||||||
|
use crate::state::SharedState;
|
||||||
|
use blockchain_core::Block;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct MineRequest {
|
||||||
|
pub miner_address: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct MiningStatus {
|
||||||
|
pub difficulty: u32,
|
||||||
|
pub block_reward: u64,
|
||||||
|
pub pending_transactions: usize,
|
||||||
|
pub chain_length: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mine_block(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Json(req): Json<MineRequest>,
|
||||||
|
) -> Result<Json<Block>, ApiError> {
|
||||||
|
let mut chain = state.write().await;
|
||||||
|
let block = chain.mine_pending(&req.miner_address).map_err(ApiError::from)?;
|
||||||
|
Ok(Json(block))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mining_status(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
) -> Json<MiningStatus> {
|
||||||
|
let chain = state.read().await;
|
||||||
|
Json(MiningStatus {
|
||||||
|
difficulty: chain.difficulty,
|
||||||
|
block_reward: chain.block_reward,
|
||||||
|
pending_transactions: chain.pending_transactions.len(),
|
||||||
|
chain_length: chain.len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
47
node/src/api/mod.rs
Normal file
47
node/src/api/mod.rs
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
pub mod blocks;
|
||||||
|
pub mod chain;
|
||||||
|
pub mod errors;
|
||||||
|
pub mod mining;
|
||||||
|
pub mod transactions;
|
||||||
|
pub mod wallets;
|
||||||
|
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::Router;
|
||||||
|
use tower_http::cors::CorsLayer;
|
||||||
|
|
||||||
|
use crate::state::SharedState;
|
||||||
|
|
||||||
|
pub fn create_router(state: SharedState) -> Router {
|
||||||
|
let cors = CorsLayer::permissive();
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
// Blocks
|
||||||
|
.route("/api/blocks", get(blocks::list_blocks))
|
||||||
|
.route("/api/blocks/{hash}", get(blocks::get_block))
|
||||||
|
// Transactions
|
||||||
|
.route("/api/transactions", post(transactions::submit_transaction))
|
||||||
|
.route(
|
||||||
|
"/api/transactions/pending",
|
||||||
|
get(transactions::list_pending),
|
||||||
|
)
|
||||||
|
// Wallets
|
||||||
|
.route("/api/wallets", post(wallets::create_wallet))
|
||||||
|
.route(
|
||||||
|
"/api/wallets/{address}/balance",
|
||||||
|
get(wallets::get_balance),
|
||||||
|
)
|
||||||
|
// Mining
|
||||||
|
.route("/api/mine", post(mining::mine_block))
|
||||||
|
.route("/api/mining/status", get(mining::mining_status))
|
||||||
|
// Chain
|
||||||
|
.route("/api/chain/info", get(chain::chain_info))
|
||||||
|
.route("/api/chain/validate", post(chain::validate_chain))
|
||||||
|
// Health
|
||||||
|
.route("/api/health", get(health))
|
||||||
|
.layer(cors)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health() -> &'static str {
|
||||||
|
"OK"
|
||||||
|
}
|
||||||
45
node/src/api/transactions.rs
Normal file
45
node/src/api/transactions.rs
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
use axum::extract::State;
|
||||||
|
use axum::Json;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::api::errors::ApiError;
|
||||||
|
use crate::state::SharedState;
|
||||||
|
use blockchain_core::Transaction;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SubmitTransactionRequest {
|
||||||
|
pub from: String,
|
||||||
|
pub to: String,
|
||||||
|
pub amount: u64,
|
||||||
|
pub nonce: u64,
|
||||||
|
pub timestamp: i64,
|
||||||
|
pub signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn submit_transaction(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Json(req): Json<SubmitTransactionRequest>,
|
||||||
|
) -> Result<Json<Transaction>, ApiError> {
|
||||||
|
let tx = Transaction {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
from: req.from,
|
||||||
|
to: req.to,
|
||||||
|
amount: req.amount,
|
||||||
|
nonce: req.nonce,
|
||||||
|
timestamp: chrono::DateTime::from_timestamp_millis(req.timestamp)
|
||||||
|
.unwrap_or_else(chrono::Utc::now),
|
||||||
|
signature: req.signature,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut chain = state.write().await;
|
||||||
|
chain.add_transaction(tx.clone()).map_err(ApiError::from)?;
|
||||||
|
|
||||||
|
Ok(Json(tx))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_pending(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
) -> Json<Vec<Transaction>> {
|
||||||
|
let chain = state.read().await;
|
||||||
|
Json(chain.pending_transactions.clone())
|
||||||
|
}
|
||||||
44
node/src/api/wallets.rs
Normal file
44
node/src/api/wallets.rs
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::Json;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::api::errors::ApiError;
|
||||||
|
use crate::state::SharedState;
|
||||||
|
use blockchain_core::Wallet;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct WalletResponse {
|
||||||
|
pub address: String,
|
||||||
|
pub signing_key_hex: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct BalanceResponse {
|
||||||
|
pub address: String,
|
||||||
|
pub balance: u64,
|
||||||
|
pub nonce: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_wallet() -> Json<WalletResponse> {
|
||||||
|
let wallet = Wallet::new();
|
||||||
|
Json(WalletResponse {
|
||||||
|
address: wallet.address,
|
||||||
|
signing_key_hex: wallet
|
||||||
|
.signing_key_bytes
|
||||||
|
.map(|bytes| hex::encode(&bytes)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_balance(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Path(address): Path<String>,
|
||||||
|
) -> Result<Json<BalanceResponse>, ApiError> {
|
||||||
|
let chain = state.read().await;
|
||||||
|
let account = chain.get_account(&address);
|
||||||
|
|
||||||
|
Ok(Json(BalanceResponse {
|
||||||
|
address,
|
||||||
|
balance: account.balance,
|
||||||
|
nonce: account.nonce,
|
||||||
|
}))
|
||||||
|
}
|
||||||
39
node/src/main.rs
Normal file
39
node/src/main.rs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
mod api;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
use blockchain_core::persistence;
|
||||||
|
use blockchain_core::Blockchain;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// Initialize logging
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(EnvFilter::from_default_env().add_directive("blockchain_node=info".parse().unwrap()))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// Load existing chain or create new
|
||||||
|
let chain = match persistence::load_json::<Blockchain>() {
|
||||||
|
Ok(Some(chain)) => {
|
||||||
|
tracing::info!("Loaded existing chain ({} blocks)", chain.len());
|
||||||
|
chain
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
tracing::info!("No existing chain found, creating new blockchain");
|
||||||
|
Blockchain::new()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to load chain: {}, creating new", e);
|
||||||
|
Blockchain::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let shared_state = state::new_shared_state(chain);
|
||||||
|
let router = api::create_router(shared_state);
|
||||||
|
|
||||||
|
let addr = "0.0.0.0:3000";
|
||||||
|
tracing::info!("Blockchain node starting on {}", addr);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||||
|
axum::serve(listener, router).await.unwrap();
|
||||||
|
}
|
||||||
12
node/src/state.rs
Normal file
12
node/src/state.rs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use blockchain_core::Blockchain;
|
||||||
|
|
||||||
|
/// Shared application state for the axum server.
|
||||||
|
/// `Arc<RwLock<T>>` allows concurrent reads and exclusive writes across handlers.
|
||||||
|
pub type SharedState = Arc<RwLock<Blockchain>>;
|
||||||
|
|
||||||
|
pub fn new_shared_state(chain: Blockchain) -> SharedState {
|
||||||
|
Arc::new(RwLock::new(chain))
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user