feat(nu-node): single-validator block production loop

- nu-vm/engine.rs: execute_block runs all txs in a block against StateDb;
  TokenTransfer fully applied, other variants return "not implemented" receipt
- StateAccessor trait: set_balance/inc_nonce now take &self (RocksDB interior mutability)
- src/block_loop.rs: tokio task that produces a block each slot (6s) in dev mode;
  drains mempool, executes txs, removes successful ones, persists block to RocksDB
- StateDb wrapped in Arc<Mutex> — block loop holds write lock per block, RPC holds
  read lock for nu_getAccount
- main.rs: spawns block_loop when --dev --validator flags are set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mukan Erkin TÖRÜK 2026-04-24 10:54:48 +03:00
parent fd829ba1dd
commit 265097375a
11 changed files with 241 additions and 40 deletions

5
Cargo.lock generated
View file

@ -808,7 +808,9 @@ name = "nu-node"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono",
"clap", "clap",
"hex",
"nu-block", "nu-block",
"nu-consensus", "nu-consensus",
"nu-mempool", "nu-mempool",
@ -816,6 +818,8 @@ dependencies = [
"nu-state", "nu-state",
"nu-vm", "nu-vm",
"serde", "serde",
"serde_json",
"sha2",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@ -855,6 +859,7 @@ name = "nu-vm"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"nu-block",
"nu-state", "nu-state",
"serde", "serde",
"serde_json", "serde_json",

View file

@ -42,6 +42,10 @@ anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
clap.workspace = true clap.workspace = true
sha2.workspace = true
hex.workspace = true
chrono.workspace = true
serde_json.workspace = true
nu-consensus = { path = "crates/nu-consensus" } nu-consensus = { path = "crates/nu-consensus" }
nu-mempool = { path = "crates/nu-mempool" } nu-mempool = { path = "crates/nu-mempool" }
nu-state = { path = "crates/nu-state" } nu-state = { path = "crates/nu-state" }

View file

@ -10,7 +10,7 @@ use nu_state::account::AccountState;
pub async fn dispatch(req: JsonRpcRequest, state: &AppState) -> JsonRpcResponse { pub async fn dispatch(req: JsonRpcRequest, state: &AppState) -> JsonRpcResponse {
match req.method.as_str() { match req.method.as_str() {
"nu_chainInfo" => handle_chain_info(&req, state), "nu_chainInfo" => handle_chain_info(&req, state),
"nu_getAccount" => handle_get_account(&req, state), "nu_getAccount" => handle_get_account(&req, state).await,
"nu_sendRawTx" => handle_send_raw_tx(&req, state).await, "nu_sendRawTx" => handle_send_raw_tx(&req, state).await,
"nu_getBlock" => not_implemented(&req, "nu_getBlock"), "nu_getBlock" => not_implemented(&req, "nu_getBlock"),
"nu_getTx" => not_implemented(&req, "nu_getTx"), "nu_getTx" => not_implemented(&req, "nu_getTx"),
@ -33,14 +33,15 @@ fn handle_chain_info(req: &JsonRpcRequest, state: &AppState) -> JsonRpcResponse
) )
} }
fn handle_get_account(req: &JsonRpcRequest, state: &AppState) -> JsonRpcResponse { async fn handle_get_account(req: &JsonRpcRequest, state: &AppState) -> JsonRpcResponse {
let address = match req.params.get(0).and_then(|v| v.as_str()) { let address = match req.params.get(0).and_then(|v| v.as_str()) {
Some(a) => a.to_string(), Some(a) => a.to_string(),
None => return JsonRpcResponse::err(req.id.clone(), -32602, "Missing address param".into()), None => return JsonRpcResponse::err(req.id.clone(), -32602, "Missing address param".into()),
}; };
let key = format!("account:{address}"); let key = format!("account:{address}");
match state.db.get::<AccountState>(&key) { let db = state.db.lock().await;
match db.get::<AccountState>(&key) {
Ok(Some(account)) => { Ok(Some(account)) => {
JsonRpcResponse::ok(req.id.clone(), serde_json::to_value(account).unwrap()) JsonRpcResponse::ok(req.id.clone(), serde_json::to_value(account).unwrap())
} }
@ -55,23 +56,16 @@ fn handle_get_account(req: &JsonRpcRequest, state: &AppState) -> JsonRpcResponse
async fn handle_send_raw_tx(req: &JsonRpcRequest, state: &AppState) -> JsonRpcResponse { async fn handle_send_raw_tx(req: &JsonRpcRequest, state: &AppState) -> JsonRpcResponse {
let raw_value = match req.params.get(0) { let raw_value = match req.params.get(0) {
Some(v) => v.clone(), Some(v) => v.clone(),
None => { None => return JsonRpcResponse::err(req.id.clone(), -32602, "Missing tx param".into()),
return JsonRpcResponse::err(req.id.clone(), -32602, "Missing tx param".into())
}
}; };
let tx: RawTransaction = match serde_json::from_value(raw_value) { let tx: RawTransaction = match serde_json::from_value(raw_value) {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
return JsonRpcResponse::err( return JsonRpcResponse::err(req.id.clone(), -32602, format!("Invalid tx format: {e}"))
req.id.clone(),
-32602,
format!("Invalid tx format: {e}"),
)
} }
}; };
// Basic structural validation before touching mempool
if tx.tx_id.is_empty() { if tx.tx_id.is_empty() {
return JsonRpcResponse::err(req.id.clone(), -32602, "tx_id is required".into()); return JsonRpcResponse::err(req.id.clone(), -32602, "tx_id is required".into());
} }
@ -89,14 +83,10 @@ async fn handle_send_raw_tx(req: &JsonRpcRequest, state: &AppState) -> JsonRpcRe
} }
if pool.insert(tx, now_ms) { if pool.insert(tx, now_ms) {
tracing::info!("tx accepted into mempool: {tx_id} (pool size: {})", pool.len()); tracing::info!("tx accepted: {tx_id} (pool size: {})", pool.len());
JsonRpcResponse::ok(req.id.clone(), json!({ "tx_id": tx_id })) JsonRpcResponse::ok(req.id.clone(), json!({ "tx_id": tx_id }))
} else { } else {
JsonRpcResponse::err( JsonRpcResponse::err(req.id.clone(), -32000, "mempool full or sender limit reached".into())
req.id.clone(),
-32000,
"mempool full or sender limit reached".into(),
)
} }
} }

View file

@ -14,10 +14,10 @@ use crate::{
types::{JsonRpcRequest, JsonRpcResponse}, types::{JsonRpcRequest, JsonRpcResponse},
}; };
use nu_mempool::Mempool; use nu_mempool::Mempool;
use nu_state::db::StateDb; use nu_state::StateDb;
pub struct AppState { pub struct AppState {
pub db: Arc<StateDb>, pub db: Arc<Mutex<StateDb>>,
pub mempool: Arc<Mutex<Mempool>>, pub mempool: Arc<Mutex<Mempool>>,
pub chain_id: String, pub chain_id: String,
} }
@ -30,7 +30,7 @@ pub struct RpcServer {
impl RpcServer { impl RpcServer {
pub fn new( pub fn new(
bind_addr: impl Into<String>, bind_addr: impl Into<String>,
db: Arc<StateDb>, db: Arc<Mutex<StateDb>>,
mempool: Arc<Mutex<Mempool>>, mempool: Arc<Mutex<Mempool>>,
chain_id: String, chain_id: String,
) -> Self { ) -> Self {

View file

@ -1,10 +1,13 @@
use crate::{account::AccountState, db::StateDb}; use crate::{account::AccountState, db::StateDb};
/// Read/write access to account state.
/// `set_balance` and `inc_nonce` take `&self` because StateDb uses RocksDB's
/// interior mutability — writes do not require exclusive access at the Rust level.
pub trait StateAccessor { pub trait StateAccessor {
fn get_balance(&self, address: &str) -> u64; fn get_balance(&self, address: &str) -> u64;
fn get_nonce(&self, address: &str) -> u64; fn get_nonce(&self, address: &str) -> u64;
fn set_balance(&mut self, address: &str, balance: u64); fn set_balance(&self, address: &str, balance: u64);
fn inc_nonce(&mut self, address: &str); fn inc_nonce(&self, address: &str);
} }
impl StateAccessor for StateDb { impl StateAccessor for StateDb {
@ -26,7 +29,7 @@ impl StateAccessor for StateDb {
.unwrap_or(0) .unwrap_or(0)
} }
fn set_balance(&mut self, address: &str, balance: u64) { fn set_balance(&self, address: &str, balance: u64) {
let key = format!("account:{address}"); let key = format!("account:{address}");
let mut account = self let mut account = self
.get::<AccountState>(&key) .get::<AccountState>(&key)
@ -37,7 +40,7 @@ impl StateAccessor for StateDb {
let _ = self.put(&key, &account); let _ = self.put(&key, &account);
} }
fn inc_nonce(&mut self, address: &str) { fn inc_nonce(&self, address: &str) {
let key = format!("account:{address}"); let key = format!("account:{address}");
let mut account = self let mut account = self
.get::<AccountState>(&key) .get::<AccountState>(&key)

View file

@ -10,3 +10,4 @@ anyhow.workspace = true
thiserror.workspace = true thiserror.workspace = true
tracing.workspace = true tracing.workspace = true
nu-state = { path = "../nu-state" } nu-state = { path = "../nu-state" }
nu-block = { path = "../nu-block" }

View file

@ -0,0 +1,73 @@
use nu_block::types::{Block, RawTransaction, TxPayload, TxReceipt};
use nu_state::StateAccessor;
use crate::executor::{execute_token_transfer, ExecutionContext};
pub struct BlockResult {
pub receipts: Vec<TxReceipt>,
pub applied: u32,
pub failed: u32,
}
/// Executes all transactions in a block against the given state.
/// Each tx is validated and applied atomically; failures produce a receipt
/// but do not roll back already-applied txs.
pub fn execute_block(
block: &Block,
state: &dyn StateAccessor,
now_ms: i64,
) -> BlockResult {
let mut receipts = Vec::with_capacity(block.transactions.len());
let mut applied = 0u32;
let mut failed = 0u32;
for tx in &block.transactions {
let receipt = execute_tx(tx, state, block.header.height, now_ms);
if receipt.success { applied += 1; } else { failed += 1; }
receipts.push(receipt);
}
BlockResult { receipts, applied, failed }
}
fn execute_tx(
tx: &RawTransaction,
state: &dyn StateAccessor,
block_height: u64,
now_ms: i64,
) -> TxReceipt {
let ctx = ExecutionContext { state, block_height, now_ms };
let result = match &tx.payload {
TxPayload::TokenTransfer { to, amount } => {
execute_token_transfer(&ctx, &tx.sender, to, *amount, tx.fee, tx.nonce)
}
// Remaining variants return "not yet implemented" — applied in later Faz 1 tasks
other => {
let name = payload_name(other);
Err(crate::errors::VmError::Unknown(format!("{name} execution not yet implemented")))
}
};
match result {
Ok(()) => TxReceipt { tx_id: tx.tx_id.clone(), success: true, error: String::new() },
Err(e) => TxReceipt { tx_id: tx.tx_id.clone(), success: false, error: e.to_string() },
}
}
fn payload_name(payload: &TxPayload) -> &'static str {
match payload {
TxPayload::TokenTransfer { .. } => "TokenTransfer",
TxPayload::NodeSubmit { .. } => "NodeSubmit",
TxPayload::VoteRegister { .. } => "VoteRegister",
TxPayload::VoteCast { .. } => "VoteCast",
TxPayload::NftTransfer { .. } => "NftTransfer",
TxPayload::CollectionClaim { .. } => "CollectionClaim",
TxPayload::StakeOp { .. } => "StakeOp",
TxPayload::ValidatorRegister { .. }=> "ValidatorRegister",
TxPayload::NodeApprove { .. } => "NodeApprove",
TxPayload::NftMint { .. } => "NftMint",
TxPayload::NodeReject { .. } => "NodeReject",
TxPayload::VotingOpen { .. } => "VotingOpen",
}
}

View file

@ -2,13 +2,13 @@ use crate::errors::VmError;
use nu_state::StateAccessor; use nu_state::StateAccessor;
pub struct ExecutionContext<'a> { pub struct ExecutionContext<'a> {
pub state: &'a mut dyn StateAccessor, pub state: &'a dyn StateAccessor,
pub block_height: u64, pub block_height: u64,
pub now_ms: i64, pub now_ms: i64,
} }
pub fn execute_token_transfer( pub fn execute_token_transfer(
ctx: &mut ExecutionContext, ctx: &ExecutionContext,
sender: &str, sender: &str,
to: &str, to: &str,
amount: u64, amount: u64,

View file

@ -1,4 +1,7 @@
pub mod engine;
pub mod executor; pub mod executor;
pub mod rewards; pub mod rewards;
pub mod slashing; pub mod slashing;
pub mod errors; pub mod errors;
pub use engine::{execute_block, BlockResult};

109
src/block_loop.rs Normal file
View file

@ -0,0 +1,109 @@
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{interval, Duration};
use nu_block::{builder::BlockBuilder, types::Block};
use nu_consensus::slot::current_slot;
use nu_mempool::Mempool;
use nu_state::StateDb;
use nu_vm::execute_block;
const MAX_TX_PER_BLOCK: usize = 500;
const BLOCK_INTERVAL_MS: u64 = 6_000; // one slot
pub struct BlockLoopConfig {
pub validator_addr: String,
pub chain_id: String,
}
pub async fn run(
config: BlockLoopConfig,
db: Arc<Mutex<StateDb>>,
mempool: Arc<Mutex<Mempool>>,
) {
let mut ticker = interval(Duration::from_millis(BLOCK_INTERVAL_MS));
let mut height: u64 = 1;
let mut prev_hash = "0".repeat(64);
tracing::info!(
chain_id = %config.chain_id,
validator = %config.validator_addr,
"block loop started"
);
loop {
ticker.tick().await;
let slot = current_slot();
let now_ms = chrono::Utc::now().timestamp_millis();
let pending = {
let mut pool = mempool.lock().await;
pool.select_for_block(MAX_TX_PER_BLOCK, now_ms)
};
if pending.is_empty() {
tracing::debug!(slot, height, "no pending txs — skipping block");
continue;
}
let mut builder = BlockBuilder::new(
height,
prev_hash.clone(),
slot,
config.validator_addr.clone(),
);
for p in &pending {
builder.add_tx(p.tx.clone());
}
let block = match builder.build("0".repeat(64), vec![]) {
Ok(b) => b,
Err(e) => {
tracing::error!(slot, height, "block build error: {e}");
continue;
}
};
let result = {
let db_guard = db.lock().await;
execute_block(&block, &*db_guard, now_ms)
};
{
let mut pool = mempool.lock().await;
for r in &result.receipts {
if r.success {
pool.remove(&r.tx_id);
}
}
}
let block_key = format!("block:{height}");
{
let db_guard = db.lock().await;
if let Err(e) = db_guard.put(&block_key, &block) {
tracing::error!(height, "failed to persist block: {e}");
}
}
prev_hash = block_hash(&block);
tracing::info!(
slot,
height,
applied = result.applied,
failed = result.failed,
"block produced"
);
height += 1;
}
}
fn block_hash(block: &Block) -> String {
use sha2::{Digest, Sha256};
let data = serde_json::to_vec(&block.header).unwrap_or_default();
hex::encode(Sha256::digest(&data))
}

View file

@ -1,3 +1,5 @@
mod block_loop;
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
@ -12,14 +14,18 @@ use nu_state::StateDb;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "nu-node", version)] #[command(name = "nu-node", version)]
struct Cli { struct Cli {
/// Run in single-validator dev mode (no consensus required) /// Single-validator dev mode — no consensus, produces blocks every slot
#[arg(long)] #[arg(long)]
dev: bool, dev: bool,
/// Act as validator in this session /// Act as block-producing validator
#[arg(long)] #[arg(long)]
validator: bool, validator: bool,
/// Validator address (required when --validator is set)
#[arg(long, default_value = "0xDEV0000000000000000000000000000000000000")]
validator_addr: String,
/// JSON-RPC HTTP bind address /// JSON-RPC HTTP bind address
#[arg(long, default_value = "0.0.0.0:9545")] #[arg(long, default_value = "0.0.0.0:9545")]
rpc_addr: String, rpc_addr: String,
@ -41,15 +47,20 @@ async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
if cli.dev { let db = Arc::new(Mutex::new(StateDb::open(&cli.db_path)?));
tracing::info!("Starting in --dev mode (single validator, consensus disabled)");
}
let db = Arc::new(StateDb::open(&cli.db_path)?);
tracing::info!("State DB opened at {}", cli.db_path); tracing::info!("State DB opened at {}", cli.db_path);
let mempool = Arc::new(Mutex::new(Mempool::new())); let mempool = Arc::new(Mutex::new(Mempool::new()));
// Spawn block production loop in dev mode
if cli.dev && cli.validator {
let cfg = block_loop::BlockLoopConfig {
validator_addr: cli.validator_addr.clone(),
chain_id: cli.chain_id.clone(),
};
tokio::spawn(block_loop::run(cfg, Arc::clone(&db), Arc::clone(&mempool)));
}
let rpc = RpcServer::new( let rpc = RpcServer::new(
cli.rpc_addr.clone(), cli.rpc_addr.clone(),
Arc::clone(&db), Arc::clone(&db),
@ -58,9 +69,11 @@ async fn main() -> Result<()> {
); );
tracing::info!( tracing::info!(
"nu-node ready — chain_id={} rpc={}", chain_id = %cli.chain_id,
cli.chain_id, rpc_addr = %cli.rpc_addr,
cli.rpc_addr dev = cli.dev,
validator = cli.validator,
"nu-node ready"
); );
rpc.run().await?; rpc.run().await?;