commit 5430c34d9e637525d5386a2bc36fa775684527d0688aacce8450135c07b34447 Author: Mukan Erkin Date: Fri Apr 24 00:00:26 2026 +0300 feat(nu-node): initial Faz 0 scaffold Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7a0c90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +*.rs.bk +.env +data/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5a8bc22 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,51 @@ +# nu-node — CLAUDE.md + +L1 node implementasyonu. Consensus, mempool, state, block üretimi, RPC. + +## Crate Yapısı + +| Crate | Sorumluluk | +|-------|-----------| +| `nu-consensus` | PoN validator seçim, slot sistemi, PoN skoru, oylama scheduler | +| `nu-mempool` | Bekleyen tx havuzu; öncelik: Critical > High > Normal | +| `nu-state` | AccountState, StoryNodeState, NftState; RocksDB backend | +| `nu-block` | BlockBuilder (Merkle root), BlockVerifier | +| `nu-rpc` | JSON-RPC HTTP server + WebSocket subscription | +| `nu-vm` | Tx executor, ödül dağıtımı, slashing | + +## Kritik Kurallar + +- `unwrap()` / `expect()` sadece test kodunda. Production'da `?` veya açık hata. +- State'e dokunan her fonksiyon: önce **tam doğrulama**, sonra mutation. +- Slashing ve ödül dağıtımı **atomik** — başarısız olursa state değişmez. +- `BURN_WALLET` ve `DEV_WALLET` adresleri env variable; hardcode yasak. + +## Sabitler (nu-consensus/src/types.rs) + +``` +SLOT_DURATION_MS = 6_000 +ROUND_SIZE = 21 +MIN_VALIDATOR_STAKE = 1_000 NUT +PoN: start=1.0, max=1.8, min=0.5 + win=+0.02, lose=-0.05, honest_block=+0.01 +WHALE_CAP = 5% of total weight +``` + +## Geliştirme + +```bash +# Devnet (tek validator, consensus devre dışı) +cargo run --bin nu-node -- --dev --validator + +# Testleri çalıştır +cargo test + +# Lint +cargo fmt && cargo clippy +``` + +## Faz Durumu + +- **Faz 0 (şu an):** Scaffold tamam. Modül sınırları tanımlı, tipler yazıldı. +- **Faz 1:** Tx execution (TokenTransfer önce), tek validator devnet, JSON-RPC server. +- **Faz 2:** PoN consensus tam implementasyon, slashing, 3+ validator. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..fbd5735 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,47 @@ +[workspace] +members = [ + ".", + "crates/nu-consensus", + "crates/nu-mempool", + "crates/nu-state", + "crates/nu-block", + "crates/nu-rpc", + "crates/nu-vm", +] +resolver = "2" + +[workspace.dependencies] +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +thiserror = "1" +tracing = "1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +rocksdb = "0.22" +sha2 = "0.10" +hex = "0.4" +uuid = { version = "1", features = ["v4"] } +chrono = { version = "0.4", features = ["serde"] } + +[package] +name = "nu-node" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "nu-node" +path = "src/main.rs" + +[dependencies] +tokio.workspace = true +serde.workspace = true +anyhow.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +nu-consensus = { path = "crates/nu-consensus" } +nu-mempool = { path = "crates/nu-mempool" } +nu-state = { path = "crates/nu-state" } +nu-block = { path = "crates/nu-block" } +nu-rpc = { path = "crates/nu-rpc" } +nu-vm = { path = "crates/nu-vm" } diff --git a/crates/nu-block/Cargo.toml b/crates/nu-block/Cargo.toml new file mode 100644 index 0000000..c724143 --- /dev/null +++ b/crates/nu-block/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "nu-block" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde.workspace = true +anyhow.workspace = true +thiserror.workspace = true +sha2.workspace = true +hex.workspace = true +nu-state = { path = "../nu-state" } diff --git a/crates/nu-block/src/builder.rs b/crates/nu-block/src/builder.rs new file mode 100644 index 0000000..368bd01 --- /dev/null +++ b/crates/nu-block/src/builder.rs @@ -0,0 +1,64 @@ +use crate::types::{Block, BlockHeader, RawTransaction}; +use anyhow::Result; + +pub struct BlockBuilder { + height: u64, + prev_block_hash: String, + slot: u32, + validator_addr: String, + transactions: Vec, +} + +impl BlockBuilder { + pub fn new(height: u64, prev_block_hash: String, slot: u32, validator_addr: String) -> Self { + Self { height, prev_block_hash, slot, validator_addr, transactions: vec![] } + } + + pub fn add_tx(&mut self, tx: RawTransaction) { + self.transactions.push(tx); + } + + pub fn build(self, state_root: String, validator_sig: Vec) -> Result { + let tx_root = compute_merkle_root(&self.transactions); + let receipts_root = String::new(); // computed after execution + let header = BlockHeader { + height: self.height, + prev_block_hash: self.prev_block_hash, + timestamp: now_ms(), + validator_addr: self.validator_addr, + validator_sig, + tx_root, + state_root, + receipts_root, + slot: self.slot, + }; + Ok(Block { header, transactions: self.transactions }) + } +} + +fn compute_merkle_root(txs: &[RawTransaction]) -> String { + use sha2::{Digest, Sha256}; + if txs.is_empty() { + return "0".repeat(64); + } + let mut hashes: Vec> = txs + .iter() + .map(|t| Sha256::digest(t.tx_id.as_bytes()).to_vec()) + .collect(); + while hashes.len() > 1 { + hashes = hashes.chunks(2).map(|pair| { + let mut h = Sha256::new(); + h.update(&pair[0]); + h.update(pair.get(1).unwrap_or(&pair[0])); + h.finalize().to_vec() + }).collect(); + } + hex::encode(&hashes[0]) +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64 +} diff --git a/crates/nu-block/src/lib.rs b/crates/nu-block/src/lib.rs new file mode 100644 index 0000000..6c02d22 --- /dev/null +++ b/crates/nu-block/src/lib.rs @@ -0,0 +1,3 @@ +pub mod builder; +pub mod verifier; +pub mod types; diff --git a/crates/nu-block/src/types.rs b/crates/nu-block/src/types.rs new file mode 100644 index 0000000..d326a08 --- /dev/null +++ b/crates/nu-block/src/types.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockHeader { + pub height: u64, + pub prev_block_hash: String, + pub timestamp: i64, + pub validator_addr: String, + pub validator_sig: Vec, + pub tx_root: String, + pub state_root: String, + pub receipts_root: String, + pub slot: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawTransaction { + pub tx_id: String, + pub sender: String, + pub nonce: u64, + pub fee: u64, + pub sig: Vec, + pub payload: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Block { + pub header: BlockHeader, + pub transactions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TxReceipt { + pub tx_id: String, + pub success: bool, + pub error: String, +} diff --git a/crates/nu-block/src/verifier.rs b/crates/nu-block/src/verifier.rs new file mode 100644 index 0000000..e838c38 --- /dev/null +++ b/crates/nu-block/src/verifier.rs @@ -0,0 +1,13 @@ +use crate::types::Block; +use anyhow::{bail, Result}; + +pub fn verify_block(block: &Block, expected_prev_hash: &str) -> Result<()> { + if block.header.prev_block_hash != expected_prev_hash { + bail!("prev_block_hash mismatch"); + } + if block.header.validator_sig.is_empty() { + bail!("missing validator signature"); + } + // TODO: secp256k1 signature verification against validator_addr + Ok(()) +} diff --git a/crates/nu-consensus/Cargo.toml b/crates/nu-consensus/Cargo.toml new file mode 100644 index 0000000..59679b1 --- /dev/null +++ b/crates/nu-consensus/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "nu-consensus" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio.workspace = true +serde.workspace = true +anyhow.workspace = true +thiserror.workspace = true +tracing.workspace = true +sha2.workspace = true +hex.workspace = true +chrono.workspace = true diff --git a/crates/nu-consensus/src/lib.rs b/crates/nu-consensus/src/lib.rs new file mode 100644 index 0000000..f28c92c --- /dev/null +++ b/crates/nu-consensus/src/lib.rs @@ -0,0 +1,5 @@ +pub mod types; +pub mod scheduler; +pub mod validator_set; +pub mod pon_score; +pub mod slot; diff --git a/crates/nu-consensus/src/pon_score.rs b/crates/nu-consensus/src/pon_score.rs new file mode 100644 index 0000000..0677b4d --- /dev/null +++ b/crates/nu-consensus/src/pon_score.rs @@ -0,0 +1,19 @@ +use crate::types::*; + +pub fn update_on_vote(record: &mut ValidatorRecord, won: bool) { + let delta = if won { PON_SCORE_WIN } else { PON_SCORE_LOSE }; + record.pon_score = (record.pon_score + delta).clamp(PON_SCORE_MIN, PON_SCORE_MAX); +} + +pub fn update_on_honest_block(record: &mut ValidatorRecord) { + record.pon_score = (record.pon_score + PON_SCORE_HONEST_BLOCK) + .clamp(PON_SCORE_MIN, PON_SCORE_MAX); +} + +pub fn voting_weight(record: &ValidatorRecord, nft_depth_bonus: f64) -> f64 { + (record.stake as f64).sqrt() * record.pon_score * (1.0 + nft_depth_bonus) +} + +pub fn apply_whale_cap(weight: f64, total_weight: f64) -> f64 { + weight.min(total_weight * WHALE_CAP_RATIO) +} diff --git a/crates/nu-consensus/src/scheduler.rs b/crates/nu-consensus/src/scheduler.rs new file mode 100644 index 0000000..af2da1d --- /dev/null +++ b/crates/nu-consensus/src/scheduler.rs @@ -0,0 +1,33 @@ +// Voting cycle scheduler — produces NodeApprove/NodeReject/VotingOpen auto-transactions. +// Triggered by the block producer at slot boundaries. + +use anyhow::Result; + +pub enum SchedulerEvent { + VotingOpen { temp_id: String }, + NodeApprove { temp_id: String }, + NodeReject { temp_id: String }, +} + +pub trait PendingNodeStore { + fn nodes_ready_for_voting(&self, now_ms: i64) -> Vec<(String, i64)>; // (temp_id, submitted_at) + fn nodes_ready_for_finalization(&self, now_ms: i64) -> Vec<(String, bool)>; // (temp_id, approved) +} + +pub fn tick(store: &dyn PendingNodeStore, now_ms: i64) -> Result> { + let mut events = vec![]; + + for (temp_id, _) in store.nodes_ready_for_voting(now_ms) { + events.push(SchedulerEvent::VotingOpen { temp_id }); + } + + for (temp_id, approved) in store.nodes_ready_for_finalization(now_ms) { + if approved { + events.push(SchedulerEvent::NodeApprove { temp_id }); + } else { + events.push(SchedulerEvent::NodeReject { temp_id }); + } + } + + Ok(events) +} diff --git a/crates/nu-consensus/src/slot.rs b/crates/nu-consensus/src/slot.rs new file mode 100644 index 0000000..1601ea8 --- /dev/null +++ b/crates/nu-consensus/src/slot.rs @@ -0,0 +1,14 @@ +use crate::types::SLOT_DURATION_MS; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn current_slot() -> u32 { + let ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + (ms / SLOT_DURATION_MS) as u32 +} + +pub fn slot_start_ms(slot: u32) -> u64 { + slot as u64 * SLOT_DURATION_MS +} diff --git a/crates/nu-consensus/src/types.rs b/crates/nu-consensus/src/types.rs new file mode 100644 index 0000000..0c581a8 --- /dev/null +++ b/crates/nu-consensus/src/types.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +pub const SLOT_DURATION_MS: u64 = 6_000; +pub const ROUND_SIZE: u32 = 21; +pub const MAX_CONSECUTIVE_BLOCKS: u32 = 5; +pub const MIN_VALIDATOR_STAKE: u64 = 1_000_000_000; // 1000 NUT in micro-units +pub const PON_SCORE_INIT: f64 = 1.0; +pub const PON_SCORE_MAX: f64 = 1.8; +pub const PON_SCORE_MIN: f64 = 0.5; +pub const PON_SCORE_WIN: f64 = 0.02; +pub const PON_SCORE_LOSE: f64 = -0.05; +pub const PON_SCORE_HONEST_BLOCK: f64 = 0.01; +pub const WHALE_CAP_RATIO: f64 = 0.05; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidatorRecord { + pub address: String, + pub stake: u64, + pub pon_score: f64, + pub is_active: bool, + pub last_block: u64, + pub slash_count: u32, + pub consecutive_blocks: u32, +} + +impl ValidatorRecord { + pub fn new(address: String, stake: u64) -> Self { + Self { + address, + stake, + pon_score: PON_SCORE_INIT, + is_active: true, + last_block: 0, + slash_count: 0, + consecutive_blocks: 0, + } + } +} diff --git a/crates/nu-consensus/src/validator_set.rs b/crates/nu-consensus/src/validator_set.rs new file mode 100644 index 0000000..e63384b --- /dev/null +++ b/crates/nu-consensus/src/validator_set.rs @@ -0,0 +1,55 @@ +use crate::types::{ValidatorRecord, MAX_CONSECUTIVE_BLOCKS, MIN_VALIDATOR_STAKE}; +use sha2::{Digest, Sha256}; + +pub struct ValidatorSet { + pub validators: Vec, +} + +impl ValidatorSet { + pub fn new() -> Self { + Self { validators: vec![] } + } + + pub fn register(&mut self, record: ValidatorRecord) { + self.validators.push(record); + } + + /// Weighted shuffle seeded by slot + prev_block_hash; filters ineligible validators. + pub fn schedule(&self, slot: u32, prev_block_hash: &str) -> Vec { + let seed = Self::make_seed(slot, prev_block_hash); + + let mut candidates: Vec<&ValidatorRecord> = self + .validators + .iter() + .filter(|v| v.is_active && v.stake >= MIN_VALIDATOR_STAKE) + .filter(|v| v.consecutive_blocks < MAX_CONSECUTIVE_BLOCKS) + .collect(); + + // Weighted shuffle: higher pon_score → higher probability of early position + Self::weighted_shuffle(&mut candidates, &seed); + + candidates + .into_iter() + .take(21) + .map(|v| v.address.clone()) + .collect() + } + + fn make_seed(slot: u32, prev_hash: &str) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(slot.to_le_bytes()); + h.update(prev_hash.as_bytes()); + h.finalize().into() + } + + fn weighted_shuffle(candidates: &mut Vec<&ValidatorRecord>, seed: &[u8; 32]) { + // Fisher-Yates with pon_score-weighted random keys derived from seed + for i in (1..candidates.len()).rev() { + let key = u64::from_le_bytes(seed[..8].try_into().unwrap()) + .wrapping_add(i as u64) + .wrapping_mul(candidates[i].pon_score.to_bits()); + let j = (key as usize) % (i + 1); + candidates.swap(i, j); + } + } +} diff --git a/crates/nu-mempool/Cargo.toml b/crates/nu-mempool/Cargo.toml new file mode 100644 index 0000000..52f0bb0 --- /dev/null +++ b/crates/nu-mempool/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "nu-mempool" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio.workspace = true +serde.workspace = true +anyhow.workspace = true +thiserror.workspace = true +tracing.workspace = true +chrono.workspace = true diff --git a/crates/nu-mempool/src/lib.rs b/crates/nu-mempool/src/lib.rs new file mode 100644 index 0000000..c03b681 --- /dev/null +++ b/crates/nu-mempool/src/lib.rs @@ -0,0 +1,5 @@ +pub mod pool; +pub mod priority; +pub mod types; + +pub use pool::Mempool; diff --git a/crates/nu-mempool/src/pool.rs b/crates/nu-mempool/src/pool.rs new file mode 100644 index 0000000..a2176d9 --- /dev/null +++ b/crates/nu-mempool/src/pool.rs @@ -0,0 +1,68 @@ +use crate::types::*; +use crate::priority::TxPriority; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct PendingTx { + pub tx_id: String, + pub sender: String, + pub fee: u64, + pub priority: TxPriority, + pub received_at: i64, // Unix epoch ms + pub raw: Vec, +} + +pub struct Mempool { + txs: Vec, + sender_counts: HashMap, +} + +impl Mempool { + pub fn new() -> Self { + Self { + txs: vec![], + sender_counts: HashMap::new(), + } + } + + pub fn insert(&mut self, tx: PendingTx) -> bool { + if self.txs.len() >= MEMPOOL_MAX_TX { + return false; + } + let count = self.sender_counts.entry(tx.sender.clone()).or_insert(0); + if *count >= MAX_TX_PER_SENDER && tx.priority == TxPriority::Normal { + return false; + } + *count += 1; + self.txs.push(tx); + true + } + + pub fn select_for_block(&mut self, max_tx: usize, now_ms: i64) -> Vec { + self.evict_expired(now_ms); + + let mut sorted = self.txs.clone(); + sorted.sort_by(|a, b| { + b.priority.cmp(&a.priority) + .then(b.fee.cmp(&a.fee)) + }); + sorted.into_iter().take(max_tx).collect() + } + + pub fn remove(&mut self, tx_id: &str) { + if let Some(pos) = self.txs.iter().position(|t| t.tx_id == tx_id) { + let tx = self.txs.remove(pos); + if let Some(c) = self.sender_counts.get_mut(&tx.sender) { + *c = c.saturating_sub(1); + } + } + } + + fn evict_expired(&mut self, now_ms: i64) { + self.txs.retain(|t| now_ms - t.received_at < MEMPOOL_TTL_MS); + } + + pub fn len(&self) -> usize { + self.txs.len() + } +} diff --git a/crates/nu-mempool/src/priority.rs b/crates/nu-mempool/src/priority.rs new file mode 100644 index 0000000..84e662c --- /dev/null +++ b/crates/nu-mempool/src/priority.rs @@ -0,0 +1,9 @@ +// NodeApprove and VoteCast transactions are prioritized over regular transfers. +// Within same priority tier, higher fee wins. + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum TxPriority { + Normal = 0, + High = 1, // VoteCast, VoteRegister + Critical = 2, // NodeApprove, VotingOpen, NodeReject (auto-scheduler) +} diff --git a/crates/nu-mempool/src/types.rs b/crates/nu-mempool/src/types.rs new file mode 100644 index 0000000..7346c86 --- /dev/null +++ b/crates/nu-mempool/src/types.rs @@ -0,0 +1,3 @@ +pub const MEMPOOL_MAX_TX: usize = 10_000; +pub const MEMPOOL_TTL_MS: i64 = 3_600_000; // 1 hour +pub const MAX_TX_PER_SENDER: usize = 5; diff --git a/crates/nu-rpc/Cargo.toml b/crates/nu-rpc/Cargo.toml new file mode 100644 index 0000000..4d077bb --- /dev/null +++ b/crates/nu-rpc/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "nu-rpc" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +tracing.workspace = true diff --git a/crates/nu-rpc/src/handlers.rs b/crates/nu-rpc/src/handlers.rs new file mode 100644 index 0000000..e70962e --- /dev/null +++ b/crates/nu-rpc/src/handlers.rs @@ -0,0 +1,28 @@ +// Method dispatch — each handler maps to a nu.rpc.api method name. +// Handlers are stubs; implementation wired in Faz 1. + +use crate::types::{JsonRpcRequest, JsonRpcResponse}; + +pub fn dispatch(req: JsonRpcRequest) -> JsonRpcResponse { + match req.method.as_str() { + "nu_getBlock" => stub(&req, "nu_getBlock"), + "nu_getTx" => stub(&req, "nu_getTx"), + "nu_getAccount" => stub(&req, "nu_getAccount"), + "nu_getStory" => stub(&req, "nu_getStory"), + "nu_getNode" => stub(&req, "nu_getNode"), + "nu_getNft" => stub(&req, "nu_getNft"), + "nu_listStories" => stub(&req, "nu_listStories"), + "nu_listPendingVotes" => stub(&req, "nu_listPendingVotes"), + "nu_sendRawTx" => stub(&req, "nu_sendRawTx"), + "nu_chainInfo" => stub(&req, "nu_chainInfo"), + _ => JsonRpcResponse::err(req.id, -32601, "Method not found".into()), + } +} + +fn stub(req: &JsonRpcRequest, method: &str) -> JsonRpcResponse { + JsonRpcResponse::err( + req.id.clone(), + -32000, + format!("{method} not implemented yet"), + ) +} diff --git a/crates/nu-rpc/src/lib.rs b/crates/nu-rpc/src/lib.rs new file mode 100644 index 0000000..48cbbf7 --- /dev/null +++ b/crates/nu-rpc/src/lib.rs @@ -0,0 +1,3 @@ +pub mod server; +pub mod handlers; +pub mod types; diff --git a/crates/nu-rpc/src/server.rs b/crates/nu-rpc/src/server.rs new file mode 100644 index 0000000..5f15379 --- /dev/null +++ b/crates/nu-rpc/src/server.rs @@ -0,0 +1,19 @@ +// HTTP + WebSocket server skeleton — wired in Faz 1 with axum or hyper. +// POST /rpc → JSON-RPC dispatch +// WS /ws → event subscription stream + +pub struct RpcServer { + pub bind_addr: String, +} + +impl RpcServer { + pub fn new(bind_addr: impl Into) -> Self { + Self { bind_addr: bind_addr.into() } + } + + pub async fn run(self) -> anyhow::Result<()> { + tracing::info!("RPC server listening on {}", self.bind_addr); + // TODO Faz 1: axum router, /rpc POST handler, /ws upgrade + Ok(()) + } +} diff --git a/crates/nu-rpc/src/types.rs b/crates/nu-rpc/src/types.rs new file mode 100644 index 0000000..ecd3911 --- /dev/null +++ b/crates/nu-rpc/src/types.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + pub method: String, + pub params: serde_json::Value, + pub id: serde_json::Value, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + pub result: Option, + pub error: Option, + pub id: serde_json::Value, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcError { + pub code: i32, + pub message: String, +} + +impl JsonRpcResponse { + pub fn ok(id: serde_json::Value, result: serde_json::Value) -> Self { + Self { jsonrpc: "2.0".into(), result: Some(result), error: None, id } + } + + pub fn err(id: serde_json::Value, code: i32, message: String) -> Self { + Self { jsonrpc: "2.0".into(), result: None, error: Some(JsonRpcError { code, message }), id } + } +} diff --git a/crates/nu-state/Cargo.toml b/crates/nu-state/Cargo.toml new file mode 100644 index 0000000..920439f --- /dev/null +++ b/crates/nu-state/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "nu-state" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +thiserror.workspace = true +tracing.workspace = true +rocksdb.workspace = true diff --git a/crates/nu-state/src/account.rs b/crates/nu-state/src/account.rs new file mode 100644 index 0000000..b966f26 --- /dev/null +++ b/crates/nu-state/src/account.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AccountState { + pub address: String, + pub balance: u64, // NUT micro-units + pub nonce: u64, + pub staked: u64, + pub locked: u64, + pub locked_until: i64, // Unix epoch ms; 0 = not locked + pub pon_score: f64, + pub nft_ids: Vec, +} + +impl AccountState { + pub fn new(address: String) -> Self { + Self { address, pon_score: 1.0, ..Default::default() } + } + + pub fn available_balance(&self) -> u64 { + self.balance.saturating_sub(self.locked) + } +} diff --git a/crates/nu-state/src/db.rs b/crates/nu-state/src/db.rs new file mode 100644 index 0000000..ad8b784 --- /dev/null +++ b/crates/nu-state/src/db.rs @@ -0,0 +1,33 @@ +use anyhow::Result; +use rocksdb::{DB, Options}; +use serde::{de::DeserializeOwned, Serialize}; + +pub struct StateDb { + db: DB, +} + +impl StateDb { + pub fn open(path: &str) -> Result { + let mut opts = Options::default(); + opts.create_if_missing(true); + Ok(Self { db: DB::open(&opts, path)? }) + } + + pub fn get(&self, key: &str) -> Result> { + match self.db.get(key.as_bytes())? { + Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)), + None => Ok(None), + } + } + + pub fn put(&self, key: &str, value: &T) -> Result<()> { + let bytes = serde_json::to_vec(value)?; + self.db.put(key.as_bytes(), bytes)?; + Ok(()) + } + + pub fn delete(&self, key: &str) -> Result<()> { + self.db.delete(key.as_bytes())?; + Ok(()) + } +} diff --git a/crates/nu-state/src/lib.rs b/crates/nu-state/src/lib.rs new file mode 100644 index 0000000..8916ad9 --- /dev/null +++ b/crates/nu-state/src/lib.rs @@ -0,0 +1,6 @@ +pub mod account; +pub mod story_node; +pub mod nft; +pub mod db; + +pub use db::StateDb; diff --git a/crates/nu-state/src/nft.rs b/crates/nu-state/src/nft.rs new file mode 100644 index 0000000..faa1ac0 --- /dev/null +++ b/crates/nu-state/src/nft.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NftState { + pub nft_id: String, + pub node_id: String, + pub owner: String, + pub collection_id: String, // empty until CollectionClaim + pub depth: u32, + pub lineage: Vec, // node_ids root → this + pub minted_at: i64, +} + +/// Validates that nft_ids form a valid lineage (each id is a numeric prefix extension of the previous). +pub fn validate_lineage(nft_ids: &[String]) -> bool { + if nft_ids.is_empty() { + return false; + } + for window in nft_ids.windows(2) { + let parent = &window[0]; + let child = &window[1]; + // child must start with parent (e.g. "1" → "11" → "115") + // and child must be exactly one digit longer + if !child.starts_with(parent.as_str()) || child.len() != parent.len() + 1 { + return false; + } + } + true +} diff --git a/crates/nu-state/src/story_node.rs b/crates/nu-state/src/story_node.rs new file mode 100644 index 0000000..c81c7f2 --- /dev/null +++ b/crates/nu-state/src/story_node.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum NodeStatus { + Pending, + VotingOpen, + Approved, + Rejected, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WeightedVote { + pub voter: String, + pub approve: bool, + pub weight: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoryNodeState { + pub node_id: String, // canonical; empty until approved + pub temp_id: String, // client UUID + pub story_id: String, + pub parent_id: String, + pub author: String, + pub content_hash: String, + pub status: NodeStatus, + pub submitted_at: i64, + pub vote_open_at: i64, + pub vote_end_at: i64, + pub votes: Vec, + pub nft_id: String, // set on approval + pub entry_fee: u64, +} + +impl StoryNodeState { + pub fn approve_votes_weight(&self) -> f64 { + self.votes.iter().filter(|v| v.approve).map(|v| v.weight).sum() + } + + pub fn reject_votes_weight(&self) -> f64 { + self.votes.iter().filter(|v| !v.approve).map(|v| v.weight).sum() + } + + pub fn is_approved(&self) -> bool { + self.approve_votes_weight() > self.reject_votes_weight() + } +} diff --git a/crates/nu-vm/Cargo.toml b/crates/nu-vm/Cargo.toml new file mode 100644 index 0000000..8e4808b --- /dev/null +++ b/crates/nu-vm/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "nu-vm" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +thiserror.workspace = true +tracing.workspace = true +nu-state = { path = "../nu-state" } diff --git a/crates/nu-vm/src/errors.rs b/crates/nu-vm/src/errors.rs new file mode 100644 index 0000000..6dd98b1 --- /dev/null +++ b/crates/nu-vm/src/errors.rs @@ -0,0 +1,23 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum VmError { + #[error("insufficient balance: need {need}, have {have}")] + InsufficientBalance { need: u64, have: u64 }, + #[error("invalid nonce: expected {expected}, got {got}")] + InvalidNonce { expected: u64, got: u64 }, + #[error("invalid signature")] + InvalidSignature, + #[error("nft not owned by sender")] + NftNotOwned, + #[error("nft already claimed in another collection")] + NftAlreadyClaimed, + #[error("invalid lineage path")] + InvalidLineage, + #[error("stake below minimum")] + StakeTooLow, + #[error("double spend detected")] + DoubleSpend, + #[error("unknown: {0}")] + Unknown(String), +} diff --git a/crates/nu-vm/src/executor.rs b/crates/nu-vm/src/executor.rs new file mode 100644 index 0000000..6c80841 --- /dev/null +++ b/crates/nu-vm/src/executor.rs @@ -0,0 +1,43 @@ +// Transaction executor — validates then mutates state atomically. +// Rule: validate fully before any state mutation; on error, state is unchanged. + +use crate::errors::VmError; + +pub struct ExecutionContext<'a> { + pub state: &'a mut dyn StateAccessor, + pub block_height: u64, + pub now_ms: i64, +} + +pub trait StateAccessor { + fn get_balance(&self, address: &str) -> u64; + fn get_nonce(&self, address: &str) -> u64; + fn set_balance(&mut self, address: &str, balance: u64); + fn inc_nonce(&mut self, address: &str); +} + +pub fn execute_token_transfer( + ctx: &mut ExecutionContext, + sender: &str, + to: &str, + amount: u64, + fee: u64, + nonce: u64, +) -> Result<(), VmError> { + // Validate + let expected_nonce = ctx.state.get_nonce(sender); + if nonce != expected_nonce { + return Err(VmError::InvalidNonce { expected: expected_nonce, got: nonce }); + } + let balance = ctx.state.get_balance(sender); + let total = amount.checked_add(fee).ok_or(VmError::Unknown("overflow".into()))?; + if balance < total { + return Err(VmError::InsufficientBalance { need: total, have: balance }); + } + + // Mutate + ctx.state.set_balance(sender, balance - total); + ctx.state.set_balance(to, ctx.state.get_balance(to) + amount); + ctx.state.inc_nonce(sender); + Ok(()) +} diff --git a/crates/nu-vm/src/lib.rs b/crates/nu-vm/src/lib.rs new file mode 100644 index 0000000..f31399d --- /dev/null +++ b/crates/nu-vm/src/lib.rs @@ -0,0 +1,4 @@ +pub mod executor; +pub mod rewards; +pub mod slashing; +pub mod errors; diff --git a/crates/nu-vm/src/rewards.rs b/crates/nu-vm/src/rewards.rs new file mode 100644 index 0000000..a9901bd --- /dev/null +++ b/crates/nu-vm/src/rewards.rs @@ -0,0 +1,27 @@ +pub const NODE_REWARD_TOTAL: u64 = 100_000_000; // 100 NUT in micro-units + +pub struct RewardSplit { + pub voters: u64, // 50% + pub author: u64, // 25% + pub burn: u64, // 25% +} + +pub fn community_split() -> RewardSplit { + RewardSplit { + voters: NODE_REWARD_TOTAL / 2, + author: NODE_REWARD_TOTAL / 4, + burn: NODE_REWARD_TOTAL / 4, + } +} + +pub fn genesis_split() -> u64 { + NODE_REWARD_TOTAL // 100% to dev wallet +} + +pub fn entry_fee(reward: u64) -> u64 { + reward / 4 // 25% +} + +pub fn vote_stake_lock(reward: u64) -> u64 { + reward / 10 // 10% +} diff --git a/crates/nu-vm/src/slashing.rs b/crates/nu-vm/src/slashing.rs new file mode 100644 index 0000000..63c6491 --- /dev/null +++ b/crates/nu-vm/src/slashing.rs @@ -0,0 +1,25 @@ +use nu_state::account::AccountState; + +pub struct SlashResult { + pub amount_cut: u64, + pub to_burn: u64, + pub to_reporter: u64, +} + +pub fn slash_double_sign(validator: &mut AccountState) -> SlashResult { + let cut = validator.staked / 10; // 10% + validator.staked = validator.staked.saturating_sub(cut); + split_slash(cut) +} + +pub fn slash_invalid_block(validator: &mut AccountState) -> SlashResult { + let cut = validator.staked / 20; // 5% + validator.staked = validator.staked.saturating_sub(cut); + split_slash(cut) +} + +fn split_slash(cut: u64) -> SlashResult { + let to_burn = cut / 2; + let to_reporter = cut - to_burn; + SlashResult { amount_cut: cut, to_burn, to_reporter } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..aa0adf7 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,17 @@ +use anyhow::Result; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .init(); + + tracing::info!("nu-node starting..."); + + // TODO Faz 1: parse CLI args (--dev, --validator, --rpc-port, --db-path) + // TODO Faz 1: init StateDb, Mempool, ValidatorSet, RpcServer + // TODO Faz 1: run consensus loop + + Ok(()) +}