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"
anyhow = "1"
thiserror = "1"
tracing = "1"
tracing = "0.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"] }
axum = "0.7"
clap = { version = "4", features = ["derive"] }
[package]
name = "nu-node"
@ -39,6 +41,7 @@ serde.workspace = true
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
clap.workspace = true
nu-consensus = { path = "crates/nu-consensus" }
nu-mempool = { path = "crates/nu-mempool" }
nu-state = { path = "crates/nu-state" }

View file

@ -5,6 +5,7 @@ edition = "2021"
[dependencies]
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
thiserror.workspace = true
sha2.workspace = true

View file

@ -9,3 +9,5 @@ serde.workspace = true
serde_json.workspace = true
anyhow.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.
// Handlers are stubs; implementation wired in Faz 1.
use serde_json::json;
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() {
"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"),
"nu_chainInfo" => handle_chain_info(&req, state),
"nu_getAccount" => handle_get_account(&req, state),
"nu_sendRawTx" => handle_send_raw_tx(&req, state),
"nu_getBlock" => not_implemented(&req, "nu_getBlock"),
"nu_getTx" => not_implemented(&req, "nu_getTx"),
"nu_getStory" => not_implemented(&req, "nu_getStory"),
"nu_getNode" => not_implemented(&req, "nu_getNode"),
"nu_getNft" => not_implemented(&req, "nu_getNft"),
"nu_listStories" => not_implemented(&req, "nu_listStories"),
"nu_listPendingVotes" => not_implemented(&req, "nu_listPendingVotes"),
_ => 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(
req.id.clone(),
-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.
// POST /rpc → JSON-RPC dispatch
// WS /ws → event subscription stream
use std::sync::Arc;
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 bind_addr: String,
pub state: Arc<AppState>,
}
impl RpcServer {
pub fn new(bind_addr: impl Into<String>) -> Self {
Self { bind_addr: bind_addr.into() }
pub fn new(bind_addr: impl Into<String>, db: Arc<StateDb>, chain_id: String) -> Self {
Self {
bind_addr: bind_addr.into(),
state: Arc::new(AppState { db, chain_id }),
}
}
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
let router = Router::new()
.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(())
}
}
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 story_node;
pub mod nft;
pub mod db;
pub use accessor::StateAccessor;
pub use db::StateDb;

View file

@ -1,7 +1,5 @@
// Transaction executor — validates then mutates state atomically.
// Rule: validate fully before any state mutation; on error, state is unchanged.
use crate::errors::VmError;
use nu_state::StateAccessor;
pub struct ExecutionContext<'a> {
pub state: &'a mut dyn StateAccessor,
@ -9,13 +7,6 @@ pub struct ExecutionContext<'a> {
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,
@ -24,7 +15,6 @@ pub fn execute_token_transfer(
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 });
@ -35,9 +25,9 @@ pub fn execute_token_transfer(
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(to, ctx.state.get_balance(to) + amount);
ctx.state.set_balance(to, recipient_balance + amount);
ctx.state.inc_nonce(sender);
Ok(())
}

View file

@ -1,17 +1,54 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Parser;
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]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
tracing::info!("nu-node starting...");
let cli = Cli::parse();
// 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
if cli.dev {
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);
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(())
}