feat(nu-node): wire axum RPC server, StateAccessor impl, --dev mode entry point

- nu-rpc: axum HTTP server on /rpc, dispatches JSON-RPC requests
- nu-rpc: nu_chainInfo and nu_getAccount return real state from RocksDB
- nu-state: StateAccessor trait implemented on StateDb (get/set balance, nonce)
- nu-vm: executor uses StateAccessor from nu-state (single source of truth)
- main.rs: clap CLI with --dev --validator --rpc-addr --db-path --chain-id

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mukan Erkin TÖRÜK 2026-04-24 10:25:48 +03:00
parent f7cff4513d
commit a42ca0f8d3
10 changed files with 1723 additions and 45 deletions

1504
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -16,13 +16,15 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
anyhow = "1" anyhow = "1"
thiserror = "1" thiserror = "1"
tracing = "1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
rocksdb = "0.22" rocksdb = "0.22"
sha2 = "0.10" sha2 = "0.10"
hex = "0.4" hex = "0.4"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
axum = "0.7"
clap = { version = "4", features = ["derive"] }
[package] [package]
name = "nu-node" name = "nu-node"
@ -39,6 +41,7 @@ serde.workspace = true
anyhow.workspace = true anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
clap.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

@ -4,7 +4,8 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
serde.workspace = true serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true anyhow.workspace = true
thiserror.workspace = true thiserror.workspace = true
sha2.workspace = true sha2.workspace = true

View file

@ -9,3 +9,5 @@ serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
anyhow.workspace = true anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
axum.workspace = true
nu-state = { path = "../nu-state" }

View file

@ -1,28 +1,70 @@
// Method dispatch — each handler maps to a nu.rpc.api method name. use serde_json::json;
// Handlers are stubs; implementation wired in Faz 1.
use crate::types::{JsonRpcRequest, JsonRpcResponse}; use crate::{
server::AppState,
types::{JsonRpcRequest, JsonRpcResponse},
};
use nu_state::account::AccountState;
pub fn dispatch(req: JsonRpcRequest) -> JsonRpcResponse { pub fn dispatch(req: JsonRpcRequest, state: &AppState) -> JsonRpcResponse {
match req.method.as_str() { match req.method.as_str() {
"nu_getBlock" => stub(&req, "nu_getBlock"), "nu_chainInfo" => handle_chain_info(&req, state),
"nu_getTx" => stub(&req, "nu_getTx"), "nu_getAccount" => handle_get_account(&req, state),
"nu_getAccount" => stub(&req, "nu_getAccount"), "nu_sendRawTx" => handle_send_raw_tx(&req, state),
"nu_getStory" => stub(&req, "nu_getStory"), "nu_getBlock" => not_implemented(&req, "nu_getBlock"),
"nu_getNode" => stub(&req, "nu_getNode"), "nu_getTx" => not_implemented(&req, "nu_getTx"),
"nu_getNft" => stub(&req, "nu_getNft"), "nu_getStory" => not_implemented(&req, "nu_getStory"),
"nu_listStories" => stub(&req, "nu_listStories"), "nu_getNode" => not_implemented(&req, "nu_getNode"),
"nu_listPendingVotes" => stub(&req, "nu_listPendingVotes"), "nu_getNft" => not_implemented(&req, "nu_getNft"),
"nu_sendRawTx" => stub(&req, "nu_sendRawTx"), "nu_listStories" => not_implemented(&req, "nu_listStories"),
"nu_chainInfo" => stub(&req, "nu_chainInfo"), "nu_listPendingVotes" => not_implemented(&req, "nu_listPendingVotes"),
_ => JsonRpcResponse::err(req.id, -32601, "Method not found".into()), _ => JsonRpcResponse::err(req.id, -32601, "Method not found".into()),
} }
} }
fn stub(req: &JsonRpcRequest, method: &str) -> JsonRpcResponse { fn handle_chain_info(req: &JsonRpcRequest, state: &AppState) -> JsonRpcResponse {
JsonRpcResponse::ok(
req.id.clone(),
json!({
"chain_id": state.chain_id,
"node_version": env!("CARGO_PKG_VERSION"),
}),
)
}
fn handle_get_account(req: &JsonRpcRequest, state: &AppState) -> JsonRpcResponse {
let address = match req.params.get(0).and_then(|v| v.as_str()) {
Some(a) => a.to_string(),
None => return JsonRpcResponse::err(req.id.clone(), -32602, "Missing address param".into()),
};
let key = format!("account:{address}");
match state.db.get::<AccountState>(&key) {
Ok(Some(account)) => JsonRpcResponse::ok(req.id.clone(), serde_json::to_value(account).unwrap()),
Ok(None) => {
// Return empty account — address exists conceptually with zero balance
let empty = AccountState::new(address);
JsonRpcResponse::ok(req.id.clone(), serde_json::to_value(empty).unwrap())
}
Err(e) => JsonRpcResponse::err(req.id.clone(), -32000, e.to_string()),
}
}
fn handle_send_raw_tx(req: &JsonRpcRequest, _state: &AppState) -> JsonRpcResponse {
// Faz 1: accepts tx hex, decodes, validates, adds to mempool
// For now: echo back a stub tx_id so platform integration can proceed
let _raw = match req.params.get(0).and_then(|v| v.as_str()) {
Some(r) => r,
None => return JsonRpcResponse::err(req.id.clone(), -32602, "Missing raw tx param".into()),
};
JsonRpcResponse::err( JsonRpcResponse::err(
req.id.clone(), req.id.clone(),
-32000, -32000,
format!("{method} not implemented yet"), "nu_sendRawTx: mempool integration pending (Faz 1)".into(),
) )
} }
fn not_implemented(req: &JsonRpcRequest, method: &str) -> JsonRpcResponse {
JsonRpcResponse::err(req.id.clone(), -32000, format!("{method} not implemented yet"))
}

View file

@ -1,19 +1,66 @@
// HTTP + WebSocket server skeleton — wired in Faz 1 with axum or hyper. use std::sync::Arc;
// POST /rpc → JSON-RPC dispatch
// WS /ws → event subscription stream use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::post,
Json, Router,
};
use tokio::net::TcpListener;
use crate::{
handlers::dispatch,
types::{JsonRpcRequest, JsonRpcResponse},
};
use nu_state::db::StateDb;
pub struct AppState {
pub db: Arc<StateDb>,
pub chain_id: String,
}
pub struct RpcServer { pub struct RpcServer {
pub bind_addr: String, pub bind_addr: String,
pub state: Arc<AppState>,
} }
impl RpcServer { impl RpcServer {
pub fn new(bind_addr: impl Into<String>) -> Self { pub fn new(bind_addr: impl Into<String>, db: Arc<StateDb>, chain_id: String) -> Self {
Self { bind_addr: bind_addr.into() } Self {
bind_addr: bind_addr.into(),
state: Arc::new(AppState { db, chain_id }),
}
} }
pub async fn run(self) -> anyhow::Result<()> { pub async fn run(self) -> anyhow::Result<()> {
tracing::info!("RPC server listening on {}", self.bind_addr); let router = Router::new()
// TODO Faz 1: axum router, /rpc POST handler, /ws upgrade .route("/rpc", post(rpc_handler))
.with_state(self.state);
let listener = TcpListener::bind(&self.bind_addr).await?;
tracing::info!("RPC listening on {}", self.bind_addr);
axum::serve(listener, router).await?;
Ok(()) Ok(())
} }
} }
async fn rpc_handler(
State(state): State<Arc<AppState>>,
body: axum::body::Bytes,
) -> impl IntoResponse {
let req: JsonRpcRequest = match serde_json::from_slice(&body) {
Ok(r) => r,
Err(_) => {
let resp = JsonRpcResponse::err(
serde_json::Value::Null,
-32700,
"Parse error".into(),
);
return (StatusCode::OK, Json(resp));
}
};
let resp = dispatch(req, &state);
(StatusCode::OK, Json(resp))
}

View file

@ -0,0 +1,50 @@
use crate::{account::AccountState, db::StateDb};
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);
}
impl StateAccessor for StateDb {
fn get_balance(&self, address: &str) -> u64 {
let key = format!("account:{address}");
self.get::<AccountState>(&key)
.ok()
.flatten()
.map(|a| a.balance)
.unwrap_or(0)
}
fn get_nonce(&self, address: &str) -> u64 {
let key = format!("account:{address}");
self.get::<AccountState>(&key)
.ok()
.flatten()
.map(|a| a.nonce)
.unwrap_or(0)
}
fn set_balance(&mut self, address: &str, balance: u64) {
let key = format!("account:{address}");
let mut account = self
.get::<AccountState>(&key)
.ok()
.flatten()
.unwrap_or_else(|| AccountState::new(address.to_string()));
account.balance = balance;
let _ = self.put(&key, &account);
}
fn inc_nonce(&mut self, address: &str) {
let key = format!("account:{address}");
let mut account = self
.get::<AccountState>(&key)
.ok()
.flatten()
.unwrap_or_else(|| AccountState::new(address.to_string()));
account.nonce += 1;
let _ = self.put(&key, &account);
}
}

View file

@ -1,6 +1,8 @@
pub mod accessor;
pub mod account; pub mod account;
pub mod story_node; pub mod story_node;
pub mod nft; pub mod nft;
pub mod db; pub mod db;
pub use accessor::StateAccessor;
pub use db::StateDb; pub use db::StateDb;

View file

@ -1,19 +1,10 @@
// Transaction executor — validates then mutates state atomically.
// Rule: validate fully before any state mutation; on error, state is unchanged.
use crate::errors::VmError; use crate::errors::VmError;
use nu_state::StateAccessor;
pub struct ExecutionContext<'a> { pub struct ExecutionContext<'a> {
pub state: &'a mut dyn StateAccessor, pub state: &'a mut dyn StateAccessor,
pub block_height: u64, pub block_height: u64,
pub now_ms: i64, 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( pub fn execute_token_transfer(
@ -24,20 +15,19 @@ pub fn execute_token_transfer(
fee: u64, fee: u64,
nonce: u64, nonce: u64,
) -> Result<(), VmError> { ) -> Result<(), VmError> {
// Validate
let expected_nonce = ctx.state.get_nonce(sender); let expected_nonce = ctx.state.get_nonce(sender);
if nonce != expected_nonce { if nonce != expected_nonce {
return Err(VmError::InvalidNonce { expected: expected_nonce, got: nonce }); return Err(VmError::InvalidNonce { expected: expected_nonce, got: nonce });
} }
let balance = ctx.state.get_balance(sender); let balance = ctx.state.get_balance(sender);
let total = amount.checked_add(fee).ok_or(VmError::Unknown("overflow".into()))?; let total = amount.checked_add(fee).ok_or(VmError::Unknown("overflow".into()))?;
if balance < total { if balance < total {
return Err(VmError::InsufficientBalance { need: total, have: balance }); return Err(VmError::InsufficientBalance { need: total, have: balance });
} }
// Mutate let recipient_balance = ctx.state.get_balance(to);
ctx.state.set_balance(sender, balance - total); ctx.state.set_balance(sender, balance - total);
ctx.state.set_balance(to, ctx.state.get_balance(to) + amount); ctx.state.set_balance(to, recipient_balance + amount);
ctx.state.inc_nonce(sender); ctx.state.inc_nonce(sender);
Ok(()) Ok(())
} }

View file

@ -1,17 +1,54 @@
use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use clap::Parser;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use nu_rpc::server::RpcServer;
use nu_state::StateDb;
#[derive(Parser)]
#[command(name = "nu-node", version)]
struct Cli {
/// Run in single-validator dev mode (no consensus required)
#[arg(long)]
dev: bool,
/// Act as validator in this session
#[arg(long)]
validator: bool,
/// JSON-RPC HTTP bind address
#[arg(long, default_value = "0.0.0.0:9545")]
rpc_addr: String,
/// RocksDB data directory
#[arg(long, default_value = "./data/state")]
db_path: String,
/// Chain identifier
#[arg(long, default_value = "nu-devnet-1")]
chain_id: String,
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env()) .with_env_filter(EnvFilter::from_default_env())
.init(); .init();
tracing::info!("nu-node starting..."); let cli = Cli::parse();
// TODO Faz 1: parse CLI args (--dev, --validator, --rpc-port, --db-path) if cli.dev {
// TODO Faz 1: init StateDb, Mempool, ValidatorSet, RpcServer tracing::info!("Starting in --dev mode (single validator, consensus disabled)");
// TODO Faz 1: run consensus loop }
let db = Arc::new(StateDb::open(&cli.db_path)?);
tracing::info!("State DB opened at {}", cli.db_path);
let rpc = RpcServer::new(cli.rpc_addr.clone(), Arc::clone(&db), cli.chain_id.clone());
tracing::info!("nu-node ready — chain_id={} rpc={}", cli.chain_id, cli.rpc_addr);
rpc.run().await?;
Ok(()) Ok(())
} }