commit 64ea897cdcec229fd42b9294fd161317597fbdc2 Author: StillHammer Date: Sun Feb 1 10:12:27 2026 +0800 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f0e26a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target +*.swp +*.swo +.env +*.db +blockchain_data/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e76221e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +members = ["core", "node"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +authors = ["Alex"] +license = "MIT" diff --git a/README.md b/README.md new file mode 100644 index 0000000..54123b6 --- /dev/null +++ b/README.md @@ -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> +│ └── 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` | +| 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, +} + +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, + signature: String, // Ed25519 signature +} + +struct Wallet { + address: String, // Hex-encoded public key + signing_key_bytes: Option>, // Private key (never in API) +} + +struct Blockchain { + blocks: Vec, + pending_transactions: Vec, + accounts: HashMap, + 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` with `thiserror` + `?` operator +- **Collections**: `Vec`, `HashMap` +- **Iterators**: chain validation, transaction filtering +- **Async/await**: axum handlers, tokio runtime +- **Arc>**: 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 diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..c7cf4f8 --- /dev/null +++ b/core/Cargo.toml @@ -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"] } diff --git a/core/src/block.rs b/core/src/block.rs new file mode 100644 index 0000000..db82e6c --- /dev/null +++ b/core/src/block.rs @@ -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, + 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, +} + +impl Block { + /// Create a new block (hash is computed, nonce is 0 until mined). + pub fn new(index: u64, previous_hash: String, transactions: Vec, 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())); + } +} diff --git a/core/src/chain.rs b/core/src/chain.rs new file mode 100644 index 0000000..70be051 --- /dev/null +++ b/core/src/chain.rs @@ -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, + pub pending_transactions: Vec, + pub accounts: HashMap, + 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 { + // 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 = 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 { .. }))); + } +} diff --git a/core/src/config.rs b/core/src/config.rs new file mode 100644 index 0000000..7ea1237 --- /dev/null +++ b/core/src/config.rs @@ -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"; diff --git a/core/src/error.rs b/core/src/error.rs new file mode 100644 index 0000000..8ef20dc --- /dev/null +++ b/core/src/error.rs @@ -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 = std::result::Result; diff --git a/core/src/lib.rs b/core/src/lib.rs new file mode 100644 index 0000000..c576bb2 --- /dev/null +++ b/core/src/lib.rs @@ -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; diff --git a/core/src/mining.rs b/core/src/mining.rs new file mode 100644 index 0000000..7e957e3 --- /dev/null +++ b/core/src/mining.rs @@ -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")); + } +} diff --git a/core/src/persistence.rs b/core/src/persistence.rs new file mode 100644 index 0000000..f1baa3f --- /dev/null +++ b/core/src/persistence.rs @@ -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(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() -> Result> { + 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(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(path: &Path) -> Result> { + 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)) +} diff --git a/core/src/state.rs b/core/src/state.rs new file mode 100644 index 0000000..8f6c4e9 --- /dev/null +++ b/core/src/state.rs @@ -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); + } +} diff --git a/core/src/transaction.rs b/core/src/transaction.rs new file mode 100644 index 0000000..347a8b7 --- /dev/null +++ b/core/src/transaction.rs @@ -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, + 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 { + 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()); + } +} diff --git a/core/src/wallet.rs b/core/src/wallet.rs new file mode 100644 index 0000000..0aff967 --- /dev/null +++ b/core/src/wallet.rs @@ -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>, +} + +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 { + 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()); + } +} diff --git a/node/Cargo.toml b/node/Cargo.toml new file mode 100644 index 0000000..0f0c73a --- /dev/null +++ b/node/Cargo.toml @@ -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" diff --git a/node/src/api/blocks.rs b/node/src/api/blocks.rs new file mode 100644 index 0000000..34eecb4 --- /dev/null +++ b/node/src/api/blocks.rs @@ -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, + pub limit: Option, +} + +#[derive(Serialize)] +pub struct BlocksResponse { + pub blocks: Vec, + pub total: usize, +} + +pub async fn list_blocks( + State(state): State, + Query(params): Query, +) -> Result, 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 = chain + .blocks + .iter() + .rev() + .skip(offset) + .take(limit) + .cloned() + .collect(); + + Ok(Json(BlocksResponse { blocks, total })) +} + +pub async fn get_block( + State(state): State, + Path(hash): Path, +) -> Result, 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, + }) +} diff --git a/node/src/api/chain.rs b/node/src/api/chain.rs new file mode 100644 index 0000000..4392cde --- /dev/null +++ b/node/src/api/chain.rs @@ -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, +} + +pub async fn chain_info( + State(state): State, +) -> Json { + 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, +) -> Result, 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()), + })), + } +} diff --git a/node/src/api/errors.rs b/node/src/api/errors.rs new file mode 100644 index 0000000..b55a364 --- /dev/null +++ b/node/src/api/errors.rs @@ -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 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, + } + } +} diff --git a/node/src/api/mining.rs b/node/src/api/mining.rs new file mode 100644 index 0000000..b402b4e --- /dev/null +++ b/node/src/api/mining.rs @@ -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, + Json(req): Json, +) -> Result, 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, +) -> Json { + 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(), + }) +} diff --git a/node/src/api/mod.rs b/node/src/api/mod.rs new file mode 100644 index 0000000..5179583 --- /dev/null +++ b/node/src/api/mod.rs @@ -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" +} diff --git a/node/src/api/transactions.rs b/node/src/api/transactions.rs new file mode 100644 index 0000000..946de5c --- /dev/null +++ b/node/src/api/transactions.rs @@ -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, + Json(req): Json, +) -> Result, 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, +) -> Json> { + let chain = state.read().await; + Json(chain.pending_transactions.clone()) +} diff --git a/node/src/api/wallets.rs b/node/src/api/wallets.rs new file mode 100644 index 0000000..366732b --- /dev/null +++ b/node/src/api/wallets.rs @@ -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, +} + +#[derive(Serialize)] +pub struct BalanceResponse { + pub address: String, + pub balance: u64, + pub nonce: u64, +} + +pub async fn create_wallet() -> Json { + 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, + Path(address): Path, +) -> Result, ApiError> { + let chain = state.read().await; + let account = chain.get_account(&address); + + Ok(Json(BalanceResponse { + address, + balance: account.balance, + nonce: account.nonce, + })) +} diff --git a/node/src/main.rs b/node/src/main.rs new file mode 100644 index 0000000..6e8807f --- /dev/null +++ b/node/src/main.rs @@ -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::() { + 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(); +} diff --git a/node/src/state.rs b/node/src/state.rs new file mode 100644 index 0000000..1f9249f --- /dev/null +++ b/node/src/state.rs @@ -0,0 +1,12 @@ +use std::sync::Arc; +use tokio::sync::RwLock; + +use blockchain_core::Blockchain; + +/// Shared application state for the axum server. +/// `Arc>` allows concurrent reads and exclusive writes across handlers. +pub type SharedState = Arc>; + +pub fn new_shared_state(chain: Blockchain) -> SharedState { + Arc::new(RwLock::new(chain)) +}