feat(nu-node): initial Faz 0 scaffold
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
5430c34d9e
38 changed files with 902 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
target/
|
||||
*.rs.bk
|
||||
.env
|
||||
data/
|
||||
51
CLAUDE.md
Normal file
51
CLAUDE.md
Normal file
|
|
@ -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.
|
||||
47
Cargo.toml
Normal file
47
Cargo.toml
Normal file
|
|
@ -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" }
|
||||
12
crates/nu-block/Cargo.toml
Normal file
12
crates/nu-block/Cargo.toml
Normal file
|
|
@ -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" }
|
||||
64
crates/nu-block/src/builder.rs
Normal file
64
crates/nu-block/src/builder.rs
Normal file
|
|
@ -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<RawTransaction>,
|
||||
}
|
||||
|
||||
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<u8>) -> Result<Block> {
|
||||
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<Vec<u8>> = 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
|
||||
}
|
||||
3
crates/nu-block/src/lib.rs
Normal file
3
crates/nu-block/src/lib.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod builder;
|
||||
pub mod verifier;
|
||||
pub mod types;
|
||||
37
crates/nu-block/src/types.rs
Normal file
37
crates/nu-block/src/types.rs
Normal file
|
|
@ -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<u8>,
|
||||
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<u8>,
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Block {
|
||||
pub header: BlockHeader,
|
||||
pub transactions: Vec<RawTransaction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TxReceipt {
|
||||
pub tx_id: String,
|
||||
pub success: bool,
|
||||
pub error: String,
|
||||
}
|
||||
13
crates/nu-block/src/verifier.rs
Normal file
13
crates/nu-block/src/verifier.rs
Normal file
|
|
@ -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(())
|
||||
}
|
||||
14
crates/nu-consensus/Cargo.toml
Normal file
14
crates/nu-consensus/Cargo.toml
Normal file
|
|
@ -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
|
||||
5
crates/nu-consensus/src/lib.rs
Normal file
5
crates/nu-consensus/src/lib.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod types;
|
||||
pub mod scheduler;
|
||||
pub mod validator_set;
|
||||
pub mod pon_score;
|
||||
pub mod slot;
|
||||
19
crates/nu-consensus/src/pon_score.rs
Normal file
19
crates/nu-consensus/src/pon_score.rs
Normal file
|
|
@ -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)
|
||||
}
|
||||
33
crates/nu-consensus/src/scheduler.rs
Normal file
33
crates/nu-consensus/src/scheduler.rs
Normal file
|
|
@ -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<Vec<SchedulerEvent>> {
|
||||
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)
|
||||
}
|
||||
14
crates/nu-consensus/src/slot.rs
Normal file
14
crates/nu-consensus/src/slot.rs
Normal file
|
|
@ -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
|
||||
}
|
||||
38
crates/nu-consensus/src/types.rs
Normal file
38
crates/nu-consensus/src/types.rs
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
55
crates/nu-consensus/src/validator_set.rs
Normal file
55
crates/nu-consensus/src/validator_set.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
use crate::types::{ValidatorRecord, MAX_CONSECUTIVE_BLOCKS, MIN_VALIDATOR_STAKE};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub struct ValidatorSet {
|
||||
pub validators: Vec<ValidatorRecord>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
crates/nu-mempool/Cargo.toml
Normal file
12
crates/nu-mempool/Cargo.toml
Normal file
|
|
@ -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
|
||||
5
crates/nu-mempool/src/lib.rs
Normal file
5
crates/nu-mempool/src/lib.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod pool;
|
||||
pub mod priority;
|
||||
pub mod types;
|
||||
|
||||
pub use pool::Mempool;
|
||||
68
crates/nu-mempool/src/pool.rs
Normal file
68
crates/nu-mempool/src/pool.rs
Normal file
|
|
@ -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<u8>,
|
||||
}
|
||||
|
||||
pub struct Mempool {
|
||||
txs: Vec<PendingTx>,
|
||||
sender_counts: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
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<PendingTx> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
9
crates/nu-mempool/src/priority.rs
Normal file
9
crates/nu-mempool/src/priority.rs
Normal file
|
|
@ -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)
|
||||
}
|
||||
3
crates/nu-mempool/src/types.rs
Normal file
3
crates/nu-mempool/src/types.rs
Normal file
|
|
@ -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;
|
||||
11
crates/nu-rpc/Cargo.toml
Normal file
11
crates/nu-rpc/Cargo.toml
Normal file
|
|
@ -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
|
||||
28
crates/nu-rpc/src/handlers.rs
Normal file
28
crates/nu-rpc/src/handlers.rs
Normal file
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
3
crates/nu-rpc/src/lib.rs
Normal file
3
crates/nu-rpc/src/lib.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod server;
|
||||
pub mod handlers;
|
||||
pub mod types;
|
||||
19
crates/nu-rpc/src/server.rs
Normal file
19
crates/nu-rpc/src/server.rs
Normal file
|
|
@ -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<String>) -> 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(())
|
||||
}
|
||||
}
|
||||
33
crates/nu-rpc/src/types.rs
Normal file
33
crates/nu-rpc/src/types.rs
Normal file
|
|
@ -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<serde_json::Value>,
|
||||
pub error: Option<JsonRpcError>,
|
||||
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 }
|
||||
}
|
||||
}
|
||||
13
crates/nu-state/Cargo.toml
Normal file
13
crates/nu-state/Cargo.toml
Normal file
|
|
@ -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
|
||||
23
crates/nu-state/src/account.rs
Normal file
23
crates/nu-state/src/account.rs
Normal file
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
33
crates/nu-state/src/db.rs
Normal file
33
crates/nu-state/src/db.rs
Normal file
|
|
@ -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<Self> {
|
||||
let mut opts = Options::default();
|
||||
opts.create_if_missing(true);
|
||||
Ok(Self { db: DB::open(&opts, path)? })
|
||||
}
|
||||
|
||||
pub fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
|
||||
match self.db.get(key.as_bytes())? {
|
||||
Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn put<T: Serialize>(&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(())
|
||||
}
|
||||
}
|
||||
6
crates/nu-state/src/lib.rs
Normal file
6
crates/nu-state/src/lib.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pub mod account;
|
||||
pub mod story_node;
|
||||
pub mod nft;
|
||||
pub mod db;
|
||||
|
||||
pub use db::StateDb;
|
||||
29
crates/nu-state/src/nft.rs
Normal file
29
crates/nu-state/src/nft.rs
Normal file
|
|
@ -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<String>, // 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
|
||||
}
|
||||
47
crates/nu-state/src/story_node.rs
Normal file
47
crates/nu-state/src/story_node.rs
Normal file
|
|
@ -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<WeightedVote>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
12
crates/nu-vm/Cargo.toml
Normal file
12
crates/nu-vm/Cargo.toml
Normal file
|
|
@ -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" }
|
||||
23
crates/nu-vm/src/errors.rs
Normal file
23
crates/nu-vm/src/errors.rs
Normal file
|
|
@ -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),
|
||||
}
|
||||
43
crates/nu-vm/src/executor.rs
Normal file
43
crates/nu-vm/src/executor.rs
Normal file
|
|
@ -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(())
|
||||
}
|
||||
4
crates/nu-vm/src/lib.rs
Normal file
4
crates/nu-vm/src/lib.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod executor;
|
||||
pub mod rewards;
|
||||
pub mod slashing;
|
||||
pub mod errors;
|
||||
27
crates/nu-vm/src/rewards.rs
Normal file
27
crates/nu-vm/src/rewards.rs
Normal file
|
|
@ -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%
|
||||
}
|
||||
25
crates/nu-vm/src/slashing.rs
Normal file
25
crates/nu-vm/src/slashing.rs
Normal file
|
|
@ -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 }
|
||||
}
|
||||
17
src/main.rs
Normal file
17
src/main.rs
Normal file
|
|
@ -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(())
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue