From 40d9ac96467204f1f2ddf60522de65a7280ffa9d Mon Sep 17 00:00:00 2001 From: FlowmemoryAI <283694809+FlowmemoryAI@users.noreply.github.com> Date: Wed, 13 May 2026 18:08:10 -0500 Subject: [PATCH] Add FlowChain local node runtime --- crates/flowmemory-devnet/src/cli.rs | 839 +++++++++++++++++- crates/flowmemory-devnet/src/lib.rs | 13 +- crates/flowmemory-devnet/src/model.rs | 272 +++++- .../flowmemory-devnet/tests/devnet_tests.rs | 221 +++++ docs/FLOWCHAIN_SECOND_COMPUTER_SETUP.md | 101 ++- docs/FLOWCHAIN_TESTNET_ACCEPTANCE.md | 78 +- docs/LOCAL_DEVNET.md | 136 ++- infra/scripts/flowchain-common.ps1 | 15 + infra/scripts/flowchain-faucet.ps1 | 45 + infra/scripts/flowchain-multi-node-smoke.ps1 | 166 ++++ infra/scripts/flowchain-node-smoke.ps1 | 173 ++++ infra/scripts/flowchain-node-status.ps1 | 25 + infra/scripts/flowchain-node-stop.ps1 | 25 + infra/scripts/flowchain-node.ps1 | 45 + infra/scripts/flowchain-smoke.ps1 | 31 +- infra/scripts/flowchain-start.ps1 | 6 +- infra/scripts/flowchain-stop.ps1 | 18 +- infra/scripts/flowchain-tx.ps1 | 69 ++ package.json | 8 + 19 files changed, 2183 insertions(+), 103 deletions(-) create mode 100644 infra/scripts/flowchain-faucet.ps1 create mode 100644 infra/scripts/flowchain-multi-node-smoke.ps1 create mode 100644 infra/scripts/flowchain-node-smoke.ps1 create mode 100644 infra/scripts/flowchain-node-status.ps1 create mode 100644 infra/scripts/flowchain-node-stop.ps1 create mode 100644 infra/scripts/flowchain-node.ps1 create mode 100644 infra/scripts/flowchain-tx.ps1 diff --git a/crates/flowmemory-devnet/src/cli.rs b/crates/flowmemory-devnet/src/cli.rs index be02335d..12720c56 100644 --- a/crates/flowmemory-devnet/src/cli.rs +++ b/crates/flowmemory-devnet/src/cli.rs @@ -1,19 +1,23 @@ use crate::hash::{hash_json, normalize_value}; use crate::model::{ - FLOWPULSE_TOPIC0, ImportedFlowPulseObservation, ImportedVerifierReport, Transaction, - build_block, demo_transactions, genesis_state, queue_transaction, state_map_roots, state_root, + FLOWPULSE_TOPIC0, ImportedFlowPulseObservation, ImportedVerifierReport, LocalAuthorization, + Transaction, build_block, demo_transactions, envelope_tx, genesis_state, + queue_authorized_transaction, queue_transaction, state_map_roots, state_root, }; use crate::storage::{default_state_path, load_or_genesis, load_state, reset_state, save_state}; use anyhow::{Context, Result, anyhow}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::env; use std::fs; use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; #[derive(Debug)] pub struct Cli { state: PathBuf, + node_dir: PathBuf, command: Command, } @@ -21,14 +25,54 @@ pub struct Cli { pub enum Command { Init, ResetLocal, - Start { blocks: u64 }, - SubmitFixture { fixture: PathBuf }, - InspectState { summary: bool }, - ExportFixtures { out_dir: PathBuf }, - ExportState { out: PathBuf }, - ImportState { from: PathBuf }, - Demo { out_dir: PathBuf }, - Smoke { out_dir: PathBuf }, + Start { + blocks: u64, + }, + Node { + node_id: String, + block_ms: u64, + max_blocks: Option, + peer_config: Option, + }, + NodeStop, + NodeStatus, + Tick { + node_id: String, + peer_config: Option, + }, + SubmitTx { + tx_file: PathBuf, + authorized_by: Option, + direct: bool, + }, + Faucet { + account_id: String, + amount: u64, + reason: String, + authorized_by: Option, + direct: bool, + }, + SubmitFixture { + fixture: PathBuf, + }, + InspectState { + summary: bool, + }, + ExportFixtures { + out_dir: PathBuf, + }, + ExportState { + out: PathBuf, + }, + ImportState { + from: PathBuf, + }, + Demo { + out_dir: PathBuf, + }, + Smoke { + out_dir: PathBuf, + }, } pub fn run_cli() -> Result<()> { @@ -38,6 +82,7 @@ pub fn run_cli() -> Result<()> { fn parse_args(args: Vec) -> Result { let mut state = default_state_path(); + let mut node_dir = default_node_dir(); let mut index = 0; let mut positional = Vec::new(); @@ -50,6 +95,13 @@ fn parse_args(args: Vec) -> Result { .ok_or_else(|| anyhow!("--state requires a path"))?; state = PathBuf::from(value); } + "--node-dir" => { + index += 1; + let value = args + .get(index) + .ok_or_else(|| anyhow!("--node-dir requires a path"))?; + node_dir = PathBuf::from(value); + } "--help" | "-h" => { print_help(); std::process::exit(0); @@ -70,6 +122,39 @@ fn parse_args(args: Vec) -> Result { "start" | "run" => Command::Start { blocks: option_u64(&positional[1..], "--blocks")?.unwrap_or(1), }, + "node" => Command::Node { + node_id: option_value_optional(&positional[1..], "--node-id") + .unwrap_or_else(|| "node:local:alpha".to_string()), + block_ms: option_u64(&positional[1..], "--block-ms")?.unwrap_or(1_000), + max_blocks: option_u64(&positional[1..], "--max-blocks")?, + peer_config: option_value_optional(&positional[1..], "--peer-config") + .map(PathBuf::from), + }, + "node-stop" => Command::NodeStop, + "node-status" => Command::NodeStatus, + "tick" => Command::Tick { + node_id: option_value_optional(&positional[1..], "--node-id") + .unwrap_or_else(|| "node:local:alpha".to_string()), + peer_config: option_value_optional(&positional[1..], "--peer-config") + .map(PathBuf::from), + }, + "submit-tx" => { + let tx_file = option_value(&positional[1..], "--tx-file")?; + Command::SubmitTx { + tx_file: PathBuf::from(tx_file), + authorized_by: option_value_optional(&positional[1..], "--authorized-by"), + direct: positional.iter().any(|arg| arg == "--direct"), + } + } + "faucet" => Command::Faucet { + account_id: option_value(&positional[1..], "--account")?, + amount: option_u64(&positional[1..], "--amount")? + .ok_or_else(|| anyhow!("--amount is required"))?, + reason: option_value_optional(&positional[1..], "--reason") + .unwrap_or_else(|| "local-private-testnet-faucet".to_string()), + authorized_by: option_value_optional(&positional[1..], "--authorized-by"), + direct: positional.iter().any(|arg| arg == "--direct"), + }, "submit-fixture" => { let fixture = option_value(&positional[1..], "--fixture")?; Command::SubmitFixture { @@ -105,7 +190,15 @@ fn parse_args(args: Vec) -> Result { unknown => return Err(anyhow!("unknown command '{unknown}'")), }; - Ok(Cli { state, command }) + Ok(Cli { + state, + node_dir, + command, + }) +} + +fn default_node_dir() -> PathBuf { + PathBuf::from("devnet/local/node") } fn option_value(args: &[String], name: &str) -> Result { @@ -118,6 +211,11 @@ fn option_value(args: &[String], name: &str) -> Result { .ok_or_else(|| anyhow!("{name} requires a value")) } +fn option_value_optional(args: &[String], name: &str) -> Option { + let index = args.iter().position(|arg| arg == name)?; + args.get(index + 1).cloned() +} + fn option_u64(args: &[String], name: &str) -> Result> { let Some(index) = args.iter().position(|arg| arg == name) else { return Ok(None); @@ -133,7 +231,7 @@ fn option_u64(args: &[String], name: &str) -> Result> { fn print_help() { println!( - "flowmemory-devnet --state \n\nCommands:\n init\n reset-local\n start|run [--blocks ]\n run-block\n submit-fixture --fixture \n inspect|inspect-state [--summary]\n export|export-fixtures [--out-dir ]\n export-state [--out ]\n import-state --from \n demo [--out-dir ]\n smoke [--out-dir ]\n" + "flowmemory-devnet --state --node-dir \n\nCommands:\n init\n reset-local\n node [--node-id ] [--block-ms ] [--max-blocks ] [--peer-config ]\n node-stop\n node-status\n tick [--node-id ] [--peer-config ]\n submit-tx --tx-file [--authorized-by ] [--direct]\n faucet --account --amount [--reason ] [--authorized-by ] [--direct]\n start|run [--blocks ]\n run-block\n submit-fixture --fixture \n inspect|inspect-state [--summary]\n export|export-fixtures [--out-dir ]\n export-state [--out ]\n import-state --from \n demo [--out-dir ]\n smoke [--out-dir ]\n" ); } @@ -150,6 +248,125 @@ fn run(cli: Cli) -> Result<()> { write_runtime_boundary_files(&cli.state, &state)?; print_json(&StateSummary::from_state(&state))?; } + Command::Node { + node_id, + block_ms, + max_blocks, + peer_config, + } => { + run_node(NodeRunOptions { + state_path: cli.state, + node_dir: cli.node_dir, + node_id, + block_ms, + max_blocks, + peer_config, + })?; + } + Command::NodeStop => { + request_node_stop(&cli.node_dir)?; + let stop_path = stop_file(&cli.node_dir); + let state = load_or_genesis(&cli.state)?; + write_node_status( + &cli.node_dir, + &NodeStatus::from_state( + "stopping", + "local stop requested", + "node:local:unknown", + 0, + &cli.state, + &cli.node_dir, + &state, + 0, + 0, + None, + ), + )?; + print_json(&NodeStopSummary { + schema: "flowmemory.local_devnet.node_stop.v0".to_string(), + node_dir: cli.node_dir, + stop_file: stop_path, + requested: true, + })?; + } + Command::NodeStatus => { + let state = load_or_genesis(&cli.state)?; + let persisted_status = read_node_status(&cli.node_dir)?; + print_json(&NodeStatusSummary::from_state( + cli.state, + cli.node_dir, + &state, + persisted_status, + ))?; + } + Command::Tick { + node_id, + peer_config, + } => { + let mut state = load_or_genesis(&cli.state)?; + let peers = load_peer_config(peer_config.as_deref())?; + let sync_event = sync_from_peers(&mut state, &peers)?; + let ingested = drain_inbox(&mut state, &cli.node_dir)?; + let produced = build_block(&mut state); + save_state(&cli.state, &state)?; + write_runtime_boundary_files(&cli.state, &state)?; + let status = NodeStatus::from_state( + "ticked", + "manual tick completed", + &node_id, + std::process::id(), + &cli.state, + &cli.node_dir, + &state, + ingested.queued, + ingested.rejected, + sync_event, + ); + write_node_identity(&cli.node_dir, &node_id, &cli.state, peer_config.as_deref())?; + write_node_status(&cli.node_dir, &status)?; + print_json(&NodeTickSummary::from_block(status, produced.block_hash))?; + } + Command::SubmitTx { + tx_file, + authorized_by, + direct, + } => { + let txs = transactions_from_fixture(&tx_file)?; + let queued = if direct { + queue_txs_direct(&cli.state, txs, authorized_by)? + } else { + write_txs_to_inbox(&cli.node_dir, txs, authorized_by)? + }; + print_json(&QueuedTransactions { queued })?; + } + Command::Faucet { + account_id, + amount, + reason, + authorized_by, + direct, + } => { + let faucet_record_id = crate::hash::hash_json( + "flowmemory.local_devnet.faucet_record_id.v0", + &serde_json::json!({ + "accountId": &account_id, + "amount": amount, + "reason": &reason + }), + ); + let tx = Transaction::FaucetLocalBalance { + faucet_record_id, + account_id, + amount, + reason, + }; + let queued = if direct { + queue_txs_direct(&cli.state, vec![tx], authorized_by)? + } else { + write_txs_to_inbox(&cli.node_dir, vec![tx], authorized_by)? + }; + print_json(&QueuedTransactions { queued })?; + } Command::Start { blocks } => { let mut state = load_or_genesis(&cli.state)?; let produced = build_blocks(&mut state, blocks)?; @@ -218,6 +435,415 @@ fn run(cli: Cli) -> Result<()> { Ok(()) } +#[derive(Debug)] +struct NodeRunOptions { + state_path: PathBuf, + node_dir: PathBuf, + node_id: String, + block_ms: u64, + max_blocks: Option, + peer_config: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PeerConfig { + #[serde(default = "peer_config_schema")] + schema: String, + #[serde(default)] + node_id: Option, + #[serde(default)] + peers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StaticPeer { + node_id: String, + state_path: PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PeerSyncEvent { + peer_id: String, + peer_state_path: PathBuf, + adopted_block_height: usize, + adopted_state_root: String, +} + +#[derive(Debug, Default)] +struct InboxIngestSummary { + queued: usize, + rejected: usize, +} + +fn peer_config_schema() -> String { + "flowmemory.local_devnet.static_peers.v0".to_string() +} + +fn run_node(options: NodeRunOptions) -> Result<()> { + if options.block_ms == 0 { + return Err(anyhow!("--block-ms must be greater than zero")); + } + + fs::create_dir_all(inbox_dir(&options.node_dir))?; + fs::create_dir_all(processed_dir(&options.node_dir))?; + fs::create_dir_all(rejected_dir(&options.node_dir))?; + let stop_path = stop_file(&options.node_dir); + if stop_path.exists() { + fs::remove_file(&stop_path) + .with_context(|| format!("failed to remove stale stop file {}", stop_path.display()))?; + } + + let peers = load_peer_config(options.peer_config.as_deref())?; + write_node_identity( + &options.node_dir, + &options.node_id, + &options.state_path, + options.peer_config.as_deref(), + )?; + + let mut state = load_or_genesis(&options.state_path)?; + save_state(&options.state_path, &state)?; + write_runtime_boundary_files(&options.state_path, &state)?; + + let mut produced = 0_u64; + loop { + if stop_path.exists() { + let status = NodeStatus::from_state( + "stopped", + "stop file observed", + &options.node_id, + std::process::id(), + &options.state_path, + &options.node_dir, + &state, + 0, + 0, + None, + ); + write_node_status(&options.node_dir, &status)?; + println!("{}", serde_json::to_string(&status)?); + break; + } + + let sync_event = sync_from_peers(&mut state, &peers)?; + let ingested = drain_inbox(&mut state, &options.node_dir)?; + let block = build_block(&mut state); + produced += 1; + save_state(&options.state_path, &state)?; + write_runtime_boundary_files(&options.state_path, &state)?; + + let status = NodeStatus::from_state( + "running", + "block produced", + &options.node_id, + std::process::id(), + &options.state_path, + &options.node_dir, + &state, + ingested.queued, + ingested.rejected, + sync_event, + ); + write_node_status(&options.node_dir, &status)?; + + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "schema": "flowmemory.local_devnet.node_log.v0", + "nodeId": options.node_id, + "event": "blockProduced", + "blockNumber": block.block_number, + "blockHash": block.block_hash, + "txs": block.tx_ids.len(), + "stateRoot": block.state_root + }))? + ); + + if options + .max_blocks + .is_some_and(|max_blocks| produced >= max_blocks) + { + let status = NodeStatus::from_state( + "stopped", + "max blocks reached", + &options.node_id, + std::process::id(), + &options.state_path, + &options.node_dir, + &state, + 0, + 0, + None, + ); + write_node_status(&options.node_dir, &status)?; + println!("{}", serde_json::to_string(&status)?); + break; + } + + thread::sleep(Duration::from_millis(options.block_ms)); + } + + Ok(()) +} + +fn request_node_stop(node_dir: &Path) -> Result<()> { + fs::create_dir_all(node_dir) + .with_context(|| format!("failed to create node directory {}", node_dir.display()))?; + fs::write(stop_file(node_dir), b"stop\n") + .with_context(|| format!("failed to write node stop file in {}", node_dir.display())) +} + +fn queue_txs_direct( + state_path: &Path, + txs: Vec, + authorized_by: Option, +) -> Result> { + let mut state = load_or_genesis(state_path)?; + let mut queued = Vec::new(); + for tx in txs { + let tx_id = match authorized_by.clone() { + Some(signer) => queue_authorized_transaction(&mut state, tx, signer), + None => queue_transaction(&mut state, tx), + }; + queued.push(tx_id); + } + save_state(state_path, &state)?; + Ok(queued) +} + +fn write_txs_to_inbox( + node_dir: &Path, + txs: Vec, + authorized_by: Option, +) -> Result> { + let inbox = inbox_dir(node_dir); + fs::create_dir_all(&inbox) + .with_context(|| format!("failed to create inbox directory {}", inbox.display()))?; + + let mut queued = Vec::new(); + for tx in txs { + let envelope = local_authorized_envelope(tx, authorized_by.clone()); + let tx_id = envelope.tx_id.clone(); + let path = inbox.join(format!("{}.json", file_safe_id(&tx_id))); + write_json( + path, + &serde_json::json!({ + "schema": "flowmemory.local_devnet.inbox_tx.v0", + "tx": envelope.tx, + "authorization": envelope.authorization + }), + )?; + queued.push(tx_id); + } + Ok(queued) +} + +fn drain_inbox( + state: &mut crate::model::ChainState, + node_dir: &Path, +) -> Result { + let inbox = inbox_dir(node_dir); + if !inbox.exists() { + return Ok(InboxIngestSummary::default()); + } + + fs::create_dir_all(processed_dir(node_dir))?; + fs::create_dir_all(rejected_dir(node_dir))?; + let mut files = fs::read_dir(&inbox)? + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) + }) + .collect::>(); + files.sort(); + + let mut summary = InboxIngestSummary::default(); + for path in files { + match transactions_from_inbox_file(&path) { + Ok(txs) => { + for (tx, authorization) in txs { + let mut envelope = envelope_tx(tx); + envelope.authorization = authorization; + state.pending_txs.push(envelope); + summary.queued += 1; + } + move_inbox_file(&path, &processed_dir(node_dir))?; + } + Err(error) => { + let error_path = rejected_dir(node_dir).join(format!( + "{}.error.json", + path.file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("rejected") + )); + write_json( + error_path, + &serde_json::json!({ + "schema": "flowmemory.local_devnet.rejected_inbox_tx.v0", + "source": path, + "error": error.to_string() + }), + )?; + move_inbox_file(&path, &rejected_dir(node_dir))?; + summary.rejected += 1; + } + } + } + + Ok(summary) +} + +fn move_inbox_file(path: &Path, target_dir: &Path) -> Result<()> { + fs::create_dir_all(target_dir)?; + let file_name = path + .file_name() + .ok_or_else(|| anyhow!("inbox path has no file name: {}", path.display()))?; + let target = target_dir.join(file_name); + if target.exists() { + fs::remove_file(&target)?; + } + fs::rename(path, target)?; + Ok(()) +} + +fn local_authorized_envelope( + tx: Transaction, + authorized_by: Option, +) -> crate::model::TxEnvelope { + let mut envelope = envelope_tx(tx); + if let Some(signer) = authorized_by { + envelope.authorization = Some(LocalAuthorization { + mode: "local-authorized".to_string(), + signer, + digest: envelope.tx_id.clone(), + }); + } + envelope +} + +fn load_peer_config(path: Option<&Path>) -> Result> { + let Some(path) = path else { + return Ok(Vec::new()); + }; + let body = fs::read_to_string(path) + .with_context(|| format!("failed to read peer config {}", path.display()))?; + let config: PeerConfig = serde_json::from_str(body.trim_start_matches('\u{feff}')) + .with_context(|| format!("failed to parse peer config {}", path.display()))?; + Ok(config.peers) +} + +fn sync_from_peers( + state: &mut crate::model::ChainState, + peers: &[StaticPeer], +) -> Result> { + let mut adopted = None; + for peer in peers { + if !peer.state_path.exists() { + continue; + } + let peer_state = load_state(&peer.state_path)?; + if peer_state.chain_id != state.chain_id { + continue; + } + if should_adopt_peer_state(state, &peer_state) { + let adopted_state_root = state_root(&peer_state); + let adopted_block_height = peer_state.blocks.len(); + *state = peer_state; + adopted = Some(PeerSyncEvent { + peer_id: peer.node_id.clone(), + peer_state_path: peer.state_path.clone(), + adopted_block_height, + adopted_state_root, + }); + } + } + Ok(adopted) +} + +fn should_adopt_peer_state( + local: &crate::model::ChainState, + peer: &crate::model::ChainState, +) -> bool { + let local_height = local.blocks.len(); + let peer_height = peer.blocks.len(); + if peer_height > local_height { + return true; + } + if peer_height == local_height && peer_height > 0 { + return state_root(peer) < state_root(local); + } + false +} + +fn write_node_identity( + node_dir: &Path, + node_id: &str, + state_path: &Path, + peer_config: Option<&Path>, +) -> Result<()> { + fs::create_dir_all(node_dir)?; + write_json( + node_dir.join("node-identity.json"), + &serde_json::json!({ + "schema": "flowmemory.local_devnet.node_identity.v0", + "nodeId": node_id, + "mode": "local-file-private-testnet", + "statePath": state_path, + "peerConfig": peer_config, + "localOnly": true, + "lanMode": "not exposed; static local-file peers only" + }), + ) +} + +fn write_node_status(node_dir: &Path, status: &NodeStatus) -> Result<()> { + fs::create_dir_all(node_dir)?; + write_json(node_dir.join("status.json"), status) +} + +fn read_node_status(node_dir: &Path) -> Result> { + let path = node_dir.join("status.json"); + if !path.exists() { + return Ok(None); + } + let body = fs::read_to_string(&path) + .with_context(|| format!("failed to read node status {}", path.display()))?; + serde_json::from_str(&body) + .map(Some) + .with_context(|| format!("failed to parse node status {}", path.display())) +} + +fn inbox_dir(node_dir: &Path) -> PathBuf { + node_dir.join("inbox") +} + +fn processed_dir(node_dir: &Path) -> PathBuf { + node_dir.join("processed") +} + +fn rejected_dir(node_dir: &Path) -> PathBuf { + node_dir.join("rejected") +} + +fn stop_file(node_dir: &Path) -> PathBuf { + node_dir.join("stop") +} + +fn file_safe_id(id: &str) -> String { + id.chars() + .map(|ch| match ch { + 'a'..='z' | 'A'..='Z' | '0'..='9' => ch, + _ => '-', + }) + .collect() +} + fn build_blocks( state: &mut crate::model::ChainState, blocks: u64, @@ -296,6 +922,42 @@ fn transactions_from_fixture(path: &Path) -> Result> { )) } +fn transactions_from_inbox_file( + path: &Path, +) -> Result)>> { + let body = fs::read_to_string(path) + .with_context(|| format!("failed to read inbox transaction {}", path.display()))?; + let value: Value = serde_json::from_str(body.trim_start_matches('\u{feff}')) + .with_context(|| format!("failed to parse inbox transaction {}", path.display()))?; + let authorization = authorization_from_value(value.get("authorization"))?; + + if value.get("txs").is_some() { + let txs: Vec = serde_json::from_value(value["txs"].clone()) + .with_context(|| format!("failed to parse txs in {}", path.display()))?; + return Ok(txs + .into_iter() + .map(|tx| (tx, authorization.clone())) + .collect()); + } + + if value.get("tx").is_some() { + let tx = serde_json::from_value(value["tx"].clone()) + .with_context(|| format!("failed to parse tx in {}", path.display()))?; + return Ok(vec![(tx, authorization)]); + } + + transactions_from_fixture(path).map(|txs| txs.into_iter().map(|tx| (tx, None)).collect()) +} + +fn authorization_from_value(value: Option<&Value>) -> Result> { + match value { + Some(Value::Null) | None => Ok(None), + Some(value) => serde_json::from_value(value.clone()) + .map(Some) + .context("failed to parse local authorization"), + } +} + fn observation_from_flowpulse_fixture(value: &Value) -> Result { let raw = value .get("rawLog") @@ -374,6 +1036,9 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "stateRoot": state_root(state), "mapRoots": map_roots, "blockHeight": state.blocks.len(), + "localBalances": state.local_balances, + "faucetRecords": state.faucet_records, + "balanceTransfers": state.balance_transfers, "rootfields": state.rootfields, "agentAccounts": state.agent_accounts, "modelPassports": state.model_passports, @@ -393,6 +1058,9 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "genesisConfig": state.config, "importedObservations": state.imported_observations, "operatorKeyReferences": state.operator_key_references, + "localBalances": state.local_balances, + "faucetRecords": state.faucet_records, + "balanceTransfers": state.balance_transfers, "agentAccounts": state.agent_accounts, "memoryCells": state.memory_cells, "challenges": state.challenges, @@ -407,6 +1075,9 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "schema": "flowmemory.verifier_handoff.local_devnet.v0", "genesisConfig": state.config, "operatorKeyReferences": state.operator_key_references, + "localBalances": state.local_balances, + "faucetRecords": state.faucet_records, + "balanceTransfers": state.balance_transfers, "verifierModules": state.verifier_modules, "workReceipts": state.work_receipts, "verifierReports": state.verifier_reports, @@ -429,6 +1100,9 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "blocks": state.blocks, "pendingTxs": state.pending_txs, "objects": { + "localBalances": state.local_balances, + "faucetRecords": state.faucet_records, + "balanceTransfers": state.balance_transfers, "rootfields": state.rootfields, "agentAccounts": state.agent_accounts, "modelPassports": state.model_passports, @@ -503,6 +1177,126 @@ struct QueuedTransactions { queued: Vec, } +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct NodeStatus { + schema: String, + status: String, + note: String, + node_id: String, + pid: u32, + state_path: PathBuf, + node_dir: PathBuf, + block_height: usize, + next_block_number: u64, + latest_block_hash: String, + state_root: String, + pending_txs: usize, + local_balances: usize, + faucet_records: usize, + static_peer_sync: Option, + last_ingested_txs: usize, + last_rejected_inbox_files: usize, + lan_mode: String, +} + +impl NodeStatus { + fn from_state( + status: &str, + note: &str, + node_id: &str, + pid: u32, + state_path: &Path, + node_dir: &Path, + state: &crate::model::ChainState, + last_ingested_txs: usize, + last_rejected_inbox_files: usize, + static_peer_sync: Option, + ) -> Self { + Self { + schema: "flowmemory.local_devnet.node_status.v0".to_string(), + status: status.to_string(), + note: note.to_string(), + node_id: node_id.to_string(), + pid, + state_path: state_path.to_path_buf(), + node_dir: node_dir.to_path_buf(), + block_height: state.blocks.len(), + next_block_number: state.next_block_number, + latest_block_hash: state + .blocks + .last() + .map(|block| block.block_hash.clone()) + .unwrap_or_else(|| state.parent_hash.clone()), + state_root: state_root(state), + pending_txs: state.pending_txs.len(), + local_balances: state.local_balances.len(), + faucet_records: state.faucet_records.len(), + static_peer_sync, + last_ingested_txs, + last_rejected_inbox_files, + lan_mode: "not exposed; static local-file peers only".to_string(), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct NodeStatusSummary { + schema: String, + state_path: PathBuf, + node_dir: PathBuf, + stop_requested: bool, + state: StateSummary, + persisted_status: Option, +} + +impl NodeStatusSummary { + fn from_state( + state_path: PathBuf, + node_dir: PathBuf, + state: &crate::model::ChainState, + persisted_status: Option, + ) -> Self { + let stop_requested = stop_file(&node_dir).exists(); + Self { + schema: "flowmemory.local_devnet.node_status_summary.v0".to_string(), + state_path, + node_dir, + stop_requested, + state: StateSummary::from_state(state), + persisted_status, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct NodeStopSummary { + schema: String, + node_dir: PathBuf, + stop_file: PathBuf, + requested: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct NodeTickSummary { + schema: String, + block_hash: String, + status: NodeStatus, +} + +impl NodeTickSummary { + fn from_block(status: NodeStatus, block_hash: String) -> Self { + Self { + schema: "flowmemory.local_devnet.node_tick.v0".to_string(), + block_hash, + status, + } + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct StateSummary { @@ -514,6 +1308,9 @@ struct StateSummary { state_root: String, map_roots: crate::model::StateMapRoots, operator_key_references: usize, + local_balances: usize, + faucet_records: usize, + balance_transfers: usize, pending_txs: usize, blocks: usize, rootfields: usize, @@ -543,6 +1340,9 @@ impl StateSummary { state_root: state_root(state), map_roots: state_map_roots(state), operator_key_references: state.operator_key_references.len(), + local_balances: state.local_balances.len(), + faucet_records: state.faucet_records.len(), + balance_transfers: state.balance_transfers.len(), pending_txs: state.pending_txs.len(), blocks: state.blocks.len(), rootfields: state.rootfields.len(), @@ -657,6 +1457,7 @@ struct DemoSummary { state_root: String, agent_id: String, agent_registered: bool, + local_balance_credited: bool, work_receipt_id: String, work_receipt_submitted: bool, verifier_report_id: String, @@ -681,6 +1482,7 @@ impl DemoSummary { state_root: state_root(&demo.state), agent_id: "agent:demo:alpha".to_string(), agent_registered: demo.state.agent_accounts.contains_key("agent:demo:alpha"), + local_balance_credited: demo.state.local_balances.contains_key("agent:demo:alpha"), work_receipt_id: "receipt:demo:001".to_string(), work_receipt_submitted: demo.state.work_receipts.contains_key("receipt:demo:001"), verifier_report_id: "report:demo:001".to_string(), @@ -724,6 +1526,8 @@ struct SmokeSummary { struct SmokeChecks { genesis_config_initialized: bool, operator_key_reference_present: bool, + local_balance_credited: bool, + local_balance_transferred: bool, agent_registered: bool, model_registered: bool, work_receipt_submitted: bool, @@ -753,6 +1557,15 @@ impl SmokeSummary { checks: SmokeChecks { genesis_config_initialized: demo.state.config.no_value, operator_key_reference_present: !demo.state.operator_key_references.is_empty(), + local_balance_credited: demo + .state + .local_balances + .get("agent:demo:alpha") + .is_some_and(|balance| balance.balance == 100), + local_balance_transferred: demo + .state + .balance_transfers + .contains_key("transfer:demo:operator-to-agent:001"), agent_registered: demo.state.agent_accounts.contains_key("agent:demo:alpha"), model_registered: demo .state diff --git a/crates/flowmemory-devnet/src/lib.rs b/crates/flowmemory-devnet/src/lib.rs index 55fd466f..2deff498 100644 --- a/crates/flowmemory-devnet/src/lib.rs +++ b/crates/flowmemory-devnet/src/lib.rs @@ -6,10 +6,11 @@ pub mod storage; pub use cli::run_cli; pub use hash::{canonical_json, keccak_hex}; pub use model::{ - AgentAccount, ArtifactAvailabilityProof, BaseAnchorPlaceholder, Block, BlockReceipt, - ChainState, Challenge, DevnetConfig, DevnetError, FinalityReceipt, - ImportedFlowPulseObservation, ImportedVerifierReport, MemoryCell, ModelPassport, - OperatorKeyReference, StateMapRoots, Transaction, TxEnvelope, VerifierModule, - apply_transaction, build_block, default_config, default_operator_key_references, genesis_state, - state_map_roots, state_root, + AgentAccount, ArtifactAvailabilityProof, BalanceTransfer, BaseAnchorPlaceholder, Block, + BlockReceipt, ChainState, Challenge, DevnetConfig, DevnetError, FaucetRecord, FinalityReceipt, + ImportedFlowPulseObservation, ImportedVerifierReport, LocalAuthorization, LocalBalance, + MemoryCell, ModelPassport, OperatorKeyReference, StateMapRoots, Transaction, TxEnvelope, + VerifierModule, apply_transaction, build_block, default_config, + default_operator_key_references, genesis_state, queue_authorized_transaction, state_map_roots, + state_root, }; diff --git a/crates/flowmemory-devnet/src/model.rs b/crates/flowmemory-devnet/src/model.rs index e748e211..efdf4d66 100644 --- a/crates/flowmemory-devnet/src/model.rs +++ b/crates/flowmemory-devnet/src/model.rs @@ -81,6 +81,16 @@ pub enum DevnetError { AnchorAlreadyExists(String), #[error("invalid event signature: {0}")] InvalidEventSignature(String), + #[error("local balance overflow for account: {0}")] + LocalBalanceOverflow(String), + #[error("local balance account does not exist: {0}")] + LocalBalanceMissing(String), + #[error("insufficient local balance for account: {0}")] + LocalBalanceInsufficient(String), + #[error("faucet record already exists: {0}")] + FaucetRecordAlreadyExists(String), + #[error("balance transfer already exists: {0}")] + BalanceTransferAlreadyExists(String), } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -96,6 +106,12 @@ pub struct ChainState { pub parent_hash: String, #[serde(default = "default_operator_key_references")] pub operator_key_references: BTreeMap, + #[serde(default)] + pub local_balances: BTreeMap, + #[serde(default)] + pub faucet_records: BTreeMap, + #[serde(default)] + pub balance_transfers: BTreeMap, pub rootfields: BTreeMap, #[serde(default)] pub agent_accounts: BTreeMap, @@ -151,6 +167,41 @@ pub struct OperatorKeyReference { pub crypto_schema_refs: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LocalBalance { + pub account_id: String, + pub balance: u64, + pub total_faucet_credited: u64, + pub total_sent: u64, + pub total_received: u64, + pub updated_at_block: u64, + pub local_only: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct FaucetRecord { + pub faucet_record_id: String, + pub account_id: String, + pub amount: u64, + pub reason: String, + pub credited_at_block: u64, + pub local_only: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BalanceTransfer { + pub transfer_id: String, + pub from_account_id: String, + pub to_account_id: String, + pub amount: u64, + pub memo: String, + pub transferred_at_block: u64, + pub local_only: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Rootfield { @@ -327,6 +378,12 @@ pub struct BaseAnchorPlaceholder { #[serde(default)] pub operator_key_reference_root: String, #[serde(default)] + pub local_balance_root: String, + #[serde(default)] + pub faucet_record_root: String, + #[serde(default)] + pub balance_transfer_root: String, + #[serde(default)] pub agent_account_root: String, #[serde(default)] pub model_passport_root: String, @@ -351,6 +408,19 @@ pub struct BaseAnchorPlaceholder { rename_all_fields = "camelCase" )] pub enum Transaction { + FaucetLocalBalance { + faucet_record_id: String, + account_id: String, + amount: u64, + reason: String, + }, + TransferLocalBalance { + transfer_id: String, + from_account_id: String, + to_account_id: String, + amount: u64, + memo: String, + }, RegisterRootfield { rootfield_id: String, owner: String, @@ -454,6 +524,16 @@ pub enum Transaction { pub struct TxEnvelope { pub tx_id: String, pub tx: Transaction, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authorization: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LocalAuthorization { + pub mode: String, + pub signer: String, + pub digest: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -475,6 +555,8 @@ pub struct BlockReceipt { pub tx_id: String, pub status: String, pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authorization: Option, } #[derive(Debug, Serialize)] @@ -485,6 +567,9 @@ struct StateCommitmentView<'a> { chain_id: &'a str, genesis_hash: &'a str, operator_key_references: &'a BTreeMap, + local_balances: &'a BTreeMap, + faucet_records: &'a BTreeMap, + balance_transfers: &'a BTreeMap, rootfields: &'a BTreeMap, agent_accounts: &'a BTreeMap, model_passports: &'a BTreeMap, @@ -512,6 +597,9 @@ struct RootMapView<'a, T> { #[serde(rename_all = "camelCase")] pub struct StateMapRoots { pub operator_key_reference_root: String, + pub local_balance_root: String, + pub faucet_record_root: String, + pub balance_transfer_root: String, pub rootfield_state_root: String, pub agent_account_root: String, pub model_passport_root: String, @@ -578,6 +666,9 @@ pub fn genesis_state() -> ChainState { parent_hash: config.genesis_hash.clone(), config, operator_key_references: default_operator_key_references(), + local_balances: BTreeMap::new(), + faucet_records: BTreeMap::new(), + balance_transfers: BTreeMap::new(), rootfields: BTreeMap::new(), agent_accounts: BTreeMap::new(), model_passports: BTreeMap::new(), @@ -599,7 +690,11 @@ pub fn genesis_state() -> ChainState { pub fn envelope_tx(tx: Transaction) -> TxEnvelope { let tx_id = hash_json(TX_SCHEMA, &tx); - TxEnvelope { tx_id, tx } + TxEnvelope { + tx_id, + tx, + authorization: None, + } } pub fn queue_transaction(state: &mut ChainState, tx: Transaction) -> String { @@ -609,6 +704,22 @@ pub fn queue_transaction(state: &mut ChainState, tx: Transaction) -> String { tx_id } +pub fn queue_authorized_transaction( + state: &mut ChainState, + tx: Transaction, + signer: String, +) -> String { + let mut envelope = envelope_tx(tx); + envelope.authorization = Some(LocalAuthorization { + mode: "local-authorized".to_string(), + signer, + digest: envelope.tx_id.clone(), + }); + let tx_id = envelope.tx_id.clone(); + state.pending_txs.push(envelope); + tx_id +} + pub fn state_root(state: &ChainState) -> String { let view = StateCommitmentView { schema: STATE_SCHEMA, @@ -616,6 +727,9 @@ pub fn state_root(state: &ChainState) -> String { chain_id: &state.chain_id, genesis_hash: &state.genesis_hash, operator_key_references: &state.operator_key_references, + local_balances: &state.local_balances, + faucet_records: &state.faucet_records, + balance_transfers: &state.balance_transfers, rootfields: &state.rootfields, agent_accounts: &state.agent_accounts, model_passports: &state.model_passports, @@ -647,6 +761,18 @@ pub fn state_map_roots(state: &ChainState) -> StateMapRoots { "flowmemory.local_devnet.operator_key_references.v0", &state.operator_key_references, ), + local_balance_root: map_root( + "flowmemory.local_devnet.local_balances.v0", + &state.local_balances, + ), + faucet_record_root: map_root( + "flowmemory.local_devnet.faucet_records.v0", + &state.faucet_records, + ), + balance_transfer_root: map_root( + "flowmemory.local_devnet.balance_transfers.v0", + &state.balance_transfers, + ), rootfield_state_root: map_root("flowmemory.local_devnet.rootfields.v0", &state.rootfields), agent_account_root: map_root( "flowmemory.local_devnet.agent_accounts.v0", @@ -707,6 +833,7 @@ pub fn build_block(state: &mut ChainState) -> Block { for envelope in txs { tx_ids.push(envelope.tx_id.clone()); + let authorization = envelope.authorization.clone(); let result = apply_transaction(state, &envelope.tx); receipts.push(BlockReceipt { tx_id: envelope.tx_id, @@ -717,6 +844,7 @@ pub fn build_block(state: &mut ChainState) -> Block { } .to_string(), error: result.err().map(|error| error.to_string()), + authorization, }); } @@ -747,6 +875,125 @@ pub fn build_block(state: &mut ChainState) -> Block { pub fn apply_transaction(state: &mut ChainState, tx: &Transaction) -> Result<(), DevnetError> { match tx { + Transaction::FaucetLocalBalance { + faucet_record_id, + account_id, + amount, + reason, + } => { + if state.faucet_records.contains_key(faucet_record_id) { + return Err(DevnetError::FaucetRecordAlreadyExists( + faucet_record_id.clone(), + )); + } + + let updated_at_block = state.next_block_number; + let balance = state + .local_balances + .entry(account_id.clone()) + .or_insert_with(|| LocalBalance { + account_id: account_id.clone(), + balance: 0, + total_faucet_credited: 0, + total_sent: 0, + total_received: 0, + updated_at_block, + local_only: true, + }); + balance.balance = balance + .balance + .checked_add(*amount) + .ok_or_else(|| DevnetError::LocalBalanceOverflow(account_id.clone()))?; + balance.total_faucet_credited = balance + .total_faucet_credited + .checked_add(*amount) + .ok_or_else(|| DevnetError::LocalBalanceOverflow(account_id.clone()))?; + balance.updated_at_block = updated_at_block; + + state.faucet_records.insert( + faucet_record_id.clone(), + FaucetRecord { + faucet_record_id: faucet_record_id.clone(), + account_id: account_id.clone(), + amount: *amount, + reason: reason.clone(), + credited_at_block: updated_at_block, + local_only: true, + }, + ); + } + Transaction::TransferLocalBalance { + transfer_id, + from_account_id, + to_account_id, + amount, + memo, + } => { + if state.balance_transfers.contains_key(transfer_id) { + return Err(DevnetError::BalanceTransferAlreadyExists( + transfer_id.clone(), + )); + } + + let from_balance = state + .local_balances + .get(from_account_id) + .ok_or_else(|| DevnetError::LocalBalanceMissing(from_account_id.clone()))?; + if from_balance.balance < *amount { + return Err(DevnetError::LocalBalanceInsufficient( + from_account_id.clone(), + )); + } + + let updated_at_block = state.next_block_number; + { + let from = state + .local_balances + .get_mut(from_account_id) + .expect("from balance was checked above"); + from.balance -= *amount; + from.total_sent = from + .total_sent + .checked_add(*amount) + .ok_or_else(|| DevnetError::LocalBalanceOverflow(from_account_id.clone()))?; + from.updated_at_block = updated_at_block; + } + + let to = state + .local_balances + .entry(to_account_id.clone()) + .or_insert_with(|| LocalBalance { + account_id: to_account_id.clone(), + balance: 0, + total_faucet_credited: 0, + total_sent: 0, + total_received: 0, + updated_at_block, + local_only: true, + }); + to.balance = to + .balance + .checked_add(*amount) + .ok_or_else(|| DevnetError::LocalBalanceOverflow(to_account_id.clone()))?; + to.total_received = to + .total_received + .checked_add(*amount) + .ok_or_else(|| DevnetError::LocalBalanceOverflow(to_account_id.clone()))?; + to.updated_at_block = updated_at_block; + + state.balance_transfers.insert( + transfer_id.clone(), + BalanceTransfer { + transfer_id: transfer_id.clone(), + from_account_id: from_account_id.clone(), + to_account_id: to_account_id.clone(), + amount: *amount, + memo: memo.clone(), + transferred_at_block: updated_at_block, + local_only: true, + }, + ); + } Transaction::RegisterRootfield { rootfield_id, owner, @@ -1215,6 +1462,9 @@ pub fn anchor_from_state( rootfield_state_root: &'a str, artifact_commitment_root: &'a str, operator_key_reference_root: &'a str, + local_balance_root: &'a str, + faucet_record_root: &'a str, + balance_transfer_root: &'a str, agent_account_root: &'a str, model_passport_root: &'a str, memory_cell_root: &'a str, @@ -1239,6 +1489,9 @@ pub fn anchor_from_state( rootfield_state_root: &roots.rootfield_state_root, artifact_commitment_root: &roots.artifact_commitment_root, operator_key_reference_root: &roots.operator_key_reference_root, + local_balance_root: &roots.local_balance_root, + faucet_record_root: &roots.faucet_record_root, + balance_transfer_root: &roots.balance_transfer_root, agent_account_root: &roots.agent_account_root, model_passport_root: &roots.model_passport_root, memory_cell_root: &roots.memory_cell_root, @@ -1262,6 +1515,9 @@ pub fn anchor_from_state( rootfield_state_root: roots.rootfield_state_root, artifact_commitment_root: roots.artifact_commitment_root, operator_key_reference_root: roots.operator_key_reference_root, + local_balance_root: roots.local_balance_root, + faucet_record_root: roots.faucet_record_root, + balance_transfer_root: roots.balance_transfer_root, agent_account_root: roots.agent_account_root, model_passport_root: roots.model_passport_root, memory_cell_root: roots.memory_cell_root, @@ -1278,6 +1534,7 @@ pub fn demo_transactions() -> Vec { let rootfield_id = "rootfield:demo:alpha".to_string(); let model_passport_id = "model:demo:local-alpha".to_string(); let agent_id = "agent:demo:alpha".to_string(); + let operator_account_id = "local-account:operator-demo".to_string(); let verifier_id = "verifier:local-demo".to_string(); let artifact_id = "artifact:demo:001".to_string(); let artifact_commitment = keccak_hex(b"flowmemory.demo.artifact.v0"); @@ -1306,6 +1563,19 @@ pub fn demo_transactions() -> Vec { model_passport_id: Some(model_passport_id), metadata_hash: keccak_hex(b"flowmemory.demo.agent.metadata"), }, + Transaction::FaucetLocalBalance { + faucet_record_id: "faucet:demo:operator:001".to_string(), + account_id: operator_account_id.clone(), + amount: 1_000, + reason: "local-test-units-for-private-devnet".to_string(), + }, + Transaction::TransferLocalBalance { + transfer_id: "transfer:demo:operator-to-agent:001".to_string(), + from_account_id: operator_account_id, + to_account_id: agent_id.clone(), + amount: 100, + memo: "local test-unit credit for transaction intake smoke".to_string(), + }, Transaction::RegisterVerifierModule { verifier_id: verifier_id.clone(), operator: "operator:local-demo".to_string(), diff --git a/crates/flowmemory-devnet/tests/devnet_tests.rs b/crates/flowmemory-devnet/tests/devnet_tests.rs index 09691c95..77e8fb1f 100644 --- a/crates/flowmemory-devnet/tests/devnet_tests.rs +++ b/crates/flowmemory-devnet/tests/devnet_tests.rs @@ -263,6 +263,53 @@ fn every_core_transaction_type_can_be_applied() { assert_eq!(state.base_anchors.len(), 1); } +#[test] +fn local_faucet_and_transfer_update_test_unit_ledger() { + let mut state = genesis_state(); + apply_transaction( + &mut state, + &Transaction::FaucetLocalBalance { + faucet_record_id: "faucet:unit:001".to_string(), + account_id: "local-account:alice".to_string(), + amount: 50, + reason: "unit-test".to_string(), + }, + ) + .unwrap(); + apply_transaction( + &mut state, + &Transaction::TransferLocalBalance { + transfer_id: "transfer:unit:001".to_string(), + from_account_id: "local-account:alice".to_string(), + to_account_id: "local-account:bob".to_string(), + amount: 20, + memo: "unit-test-transfer".to_string(), + }, + ) + .unwrap(); + + assert_eq!(state.local_balances["local-account:alice"].balance, 30); + assert_eq!(state.local_balances["local-account:bob"].balance, 20); + assert_eq!(state.faucet_records.len(), 1); + assert_eq!(state.balance_transfers.len(), 1); + + assert_eq!( + apply_transaction( + &mut state, + &Transaction::TransferLocalBalance { + transfer_id: "transfer:unit:002".to_string(), + from_account_id: "local-account:bob".to_string(), + to_account_id: "local-account:alice".to_string(), + amount: 30, + memo: "too-much".to_string(), + }, + ), + Err(DevnetError::LocalBalanceInsufficient( + "local-account:bob".to_string() + )) + ); +} + #[test] fn duplicate_ids_are_rejected_for_new_objects() { let mut state = genesis_state(); @@ -636,12 +683,186 @@ fn cli_export_import_state_round_trip_is_deterministic() { std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); } +#[test] +fn cli_node_runs_ten_blocks_and_includes_authorized_inbox_tx() { + let temp = temp_dir("cli-node"); + let state = temp.join("state.json"); + let node_dir = temp.join("node"); + + let faucet = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "--node-dir", + node_dir.to_str().expect("node dir"), + "faucet", + "--account", + "local-account:cli-node", + "--amount", + "9", + "--reason", + "cli-node-test", + "--authorized-by", + "local-test-operator", + ]) + .status() + .expect("submit faucet"); + assert!(faucet.success()); + + let node = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "--node-dir", + node_dir.to_str().expect("node dir"), + "node", + "--node-id", + "node:test:cli", + "--block-ms", + "1", + "--max-blocks", + "10", + ]) + .status() + .expect("run node"); + assert!(node.success()); + + let output = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "--node-dir", + node_dir.to_str().expect("node dir"), + "node-status", + ]) + .output() + .expect("node status"); + assert!(output.status.success()); + let summary: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("status summary json"); + assert_eq!(summary["state"]["blocks"], 10); + assert_eq!(summary["state"]["localBalances"], 1); + assert_eq!(summary["state"]["faucetRecords"], 1); + let state_body = std::fs::read_to_string(&state).expect("state body"); + let state_json: serde_json::Value = serde_json::from_str(&state_body).expect("state json"); + assert_eq!( + state_json["blocks"][0]["receipts"][0]["authorization"]["signer"], + "local-test-operator" + ); + assert!(node_dir.join("node-identity.json").exists()); + assert!(node_dir.join("status.json").exists()); + + std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); +} + +#[test] +fn cli_static_peer_sync_reconciles_two_local_node_states() { + let temp = temp_dir("cli-peer-sync"); + let state_a = temp.join("state-a.json"); + let state_b = temp.join("state-b.json"); + let node_a = temp.join("node-a"); + let node_b = temp.join("node-b"); + let peer_b = temp.join("node-b-peers.json"); + + let faucet = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state_a.to_str().expect("state a"), + "--node-dir", + node_a.to_str().expect("node a"), + "faucet", + "--account", + "local-account:peer-sync", + "--amount", + "11", + "--reason", + "peer-sync-test", + "--authorized-by", + "local-test-operator", + ]) + .status() + .expect("submit faucet to node a"); + assert!(faucet.success()); + + let node_a_status = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state_a.to_str().expect("state a"), + "--node-dir", + node_a.to_str().expect("node a"), + "node", + "--node-id", + "node:test:a", + "--block-ms", + "1", + "--max-blocks", + "2", + ]) + .status() + .expect("run node a"); + assert!(node_a_status.success()); + + std::fs::write( + &peer_b, + format!( + "{{\"schema\":\"flowmemory.local_devnet.static_peers.v0\",\"nodeId\":\"node:test:b\",\"peers\":[{{\"nodeId\":\"node:test:a\",\"statePath\":\"{}\"}}]}}\n", + state_a.to_string_lossy().replace('\\', "\\\\") + ), + ) + .expect("write peer config"); + + let node_b_status = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state_b.to_str().expect("state b"), + "--node-dir", + node_b.to_str().expect("node b"), + "node", + "--node-id", + "node:test:b", + "--block-ms", + "1", + "--max-blocks", + "1", + "--peer-config", + peer_b.to_str().expect("peer config"), + ]) + .status() + .expect("run node b"); + assert!(node_b_status.success()); + + let summary_a = inspect_summary(&state_a, &node_a); + let summary_b = inspect_summary(&state_b, &node_b); + assert_eq!( + summary_a["state"]["stateRoot"], + summary_b["state"]["stateRoot"] + ); + assert_eq!(summary_b["state"]["localBalances"], 1); + + std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); +} + #[test] fn zero_hash_constant_is_hex_32_bytes() { assert_eq!(ZERO_HASH.len(), 66); assert!(ZERO_HASH.starts_with("0x")); } +fn inspect_summary(state: &std::path::Path, node_dir: &std::path::Path) -> serde_json::Value { + let output = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "--node-dir", + node_dir.to_str().expect("node dir"), + "node-status", + ]) + .output() + .expect("inspect node status"); + assert!(output.status.success()); + serde_json::from_slice(&output.stdout).expect("status json") +} + fn temp_dir(name: &str) -> std::path::PathBuf { let temp = std::env::temp_dir().join(format!( "flowmemory-devnet-test-{}-{name}", diff --git a/docs/FLOWCHAIN_SECOND_COMPUTER_SETUP.md b/docs/FLOWCHAIN_SECOND_COMPUTER_SETUP.md index a6ef8096..fac2767d 100644 --- a/docs/FLOWCHAIN_SECOND_COMPUTER_SETUP.md +++ b/docs/FLOWCHAIN_SECOND_COMPUTER_SETUP.md @@ -55,9 +55,10 @@ When `gh auth login` asks questions, use GitHub.com, HTTPS, and browser login. Use this path today on a clean second computer. It validates the merged V0 launch-core, no-value local devnet prototype, dashboard workbench, hardware -simulator fixture, and Windows wrapper layer. It does not yet prove the full -native AgentAccount, ModelPassport, MemoryCell, Challenge, FinalityReceipt, or -control-plane lifecycle. +simulator fixture, Windows wrapper layer, long-running local node mode, +locally authorized transaction intake, local test-unit faucet records, and +static local-file multi-node reconciliation. Control-plane and full workbench +query coverage remain separate subsystem work. If the repo is already cloned, run from the repo root: @@ -104,6 +105,20 @@ Start the current bounded local stack: npm run flowchain:start ``` +Start a long-running local node in a separate PowerShell window: + +```powershell +npm run flowchain:node +``` + +Submit local transactions from another PowerShell window: + +```powershell +npm run flowchain:faucet +npm run flowchain:tx +npm run flowchain:node:status +``` + Run the deterministic demo and export state: ```powershell @@ -117,6 +132,13 @@ Run the full merged-surface smoke path: npm run flowchain:smoke ``` +Run just the runtime smokes: + +```powershell +npm run flowchain:node:smoke +npm run flowchain:multi-node:smoke +``` + Run the local workbench in a separate PowerShell window: ```powershell @@ -126,6 +148,7 @@ npm run workbench:dev Stop the current bounded local stack when done: ```powershell +npm run flowchain:node:stop npm run flowchain:stop ``` @@ -140,19 +163,30 @@ Expected current result: `devnet/local/operator.local.json`. - `npm run flowchain:start` regenerates launch-core fixtures and records bounded stack status under `devnet/local/flowchain-stack-status.json`. +- `npm run flowchain:node` runs the Rust node until stopped, keeps state on + disk, ingests JSON transactions from `devnet/local/node/inbox/`, writes + status under `devnet/local/node/status.json`, and produces blocks on the + configured interval. +- `npm run flowchain:faucet` submits a local test-unit faucet transaction. +- `npm run flowchain:tx` submits a sample AgentAccount/ModelPassport + transaction file, or a caller-provided transaction file with + `-- -TxFile `. +- `npm run flowchain:node:smoke` proves a node can run for at least 10 blocks, + include a locally authorized transaction, restart with state intact, and + export/import the runtime state. +- `npm run flowchain:multi-node:smoke` starts two local node processes and + proves static local-file peer reconciliation. LAN mode remains not exposed. - `npm run flowchain:demo` writes deterministic local block/state output. - `npm run flowchain:export` writes ignored export files and a zip bundle under `devnet/local/export/`. - `npm run flowchain:smoke` writes `devnet/local/smoke/flowchain-smoke-report.json` and compares deterministic - replay roots. + replay roots. It now also runs the one-node and multi-node runtime smokes. - `npm run workbench:dev` opens the existing dashboard as the local workbench. -Current stop point: if a second computer needs long-running node behavior, -control-plane queries, encrypted key storage, native AgentAccount, -ModelPassport, MemoryCell, Challenge, FinalityReceipt, or full workbench -inspection of those entities, that is still the private/local testnet package -target owned by the subsystem workstreams. +Current stop point: if a second computer needs control-plane queries, +encrypted key storage, or full workbench inspection of private/local runtime +entities, that remains owned by the subsystem workstreams. ## Final Second-Computer Path @@ -168,13 +202,14 @@ npm install --prefix crypto npm run flowchain:prereq npm run flowchain:init npm run flowchain:start +npm run flowchain:node npm run control-plane:serve npm run workbench:dev npm run flowchain:smoke npm run flowchain:export ``` -If `flowchain:start`, `control-plane:serve`, or `workbench:dev` are +If `flowchain:node`, `control-plane:serve`, or `workbench:dev` are long-running commands, run each one in its own PowerShell window and run `flowchain:smoke` from a fourth window after the services are healthy. @@ -192,9 +227,17 @@ equivalents: npm run flowchain:prereq npm run flowchain:init npm run flowchain:start +npm run flowchain:node +npm run flowchain:node:stop +npm run flowchain:node:status +npm run flowchain:tx +npm run flowchain:faucet +npm run flowchain:node:smoke +npm run flowchain:multi-node:smoke npm run flowchain:stop npm run flowchain:demo npm run flowchain:smoke +npm run flowchain:full-smoke npm run flowchain:export npm run workbench:dev ``` @@ -205,10 +248,18 @@ Current status: | --- | --- | --- | | `npm run flowchain:prereq` | Implemented | `infra/scripts/flowchain-check-prereqs.ps1` | | `npm run flowchain:init` | Implemented | `infra/scripts/flowchain-init.ps1` | -| `npm run flowchain:start` | Implemented bounded wrapper | Long-running node behavior remains missing. | -| `npm run flowchain:stop` | Implemented bounded wrapper | Use `npm run flowchain:stop -- -ResetLocalState` for an explicit reset. | +| `npm run flowchain:start` | Implemented compatibility wrapper | Prepares launch-core fixtures and points operators to `flowchain:node`. | +| `npm run flowchain:node` | Implemented | Starts the long-running Rust runtime with disk state, inbox intake, interval blocks, logs, identity, and status. | +| `npm run flowchain:node:stop` | Implemented | Writes the node stop file. | +| `npm run flowchain:node:status` | Implemented | Reads node status and state summary. | +| `npm run flowchain:tx` | Implemented | Submits a caller-provided transaction file or a sample AgentAccount/ModelPassport transaction. | +| `npm run flowchain:faucet` | Implemented | Submits a local test-unit faucet transaction. | +| `npm run flowchain:node:smoke` | Implemented | Runs bounded one-node runtime, restart, and export/import checks. | +| `npm run flowchain:multi-node:smoke` | Implemented | Proves two local node processes reconcile through static local-file peer state paths; LAN mode is not exposed. | +| `npm run flowchain:stop` | Implemented wrapper | Requests node stop and use `npm run flowchain:stop -- -ResetLocalState` for an explicit reset. | | `npm run flowchain:demo` | Implemented | Wraps the existing Rust devnet `demo`. | -| `npm run flowchain:smoke` | Implemented for merged surfaces | Native object/control-plane smoke coverage remains missing. | +| `npm run flowchain:smoke` | Implemented for merged surfaces | Includes runtime smokes; control-plane query evidence remains separate. | +| `npm run flowchain:full-smoke` | Implemented alias | Runs `flowchain:smoke`. | | `npm run flowchain:export` | Implemented | Writes ignored export directory and zip bundle. | | `npm run flowchain:import -- --BundlePath -Force` | Implemented script path | Restores local state from an exported bundle. | | `npm run workbench:dev` | Implemented | Wraps `npm run dev --prefix apps/dashboard`. | @@ -242,18 +293,32 @@ Until that exists, do not claim wallet support or value-bearing key management. ## Private Genesis And Runtime -The current devnet starts from deterministic local state and writes default -state under: +The devnet starts from deterministic local state and writes default state under: ```text devnet/local/state.json ``` +The long-running node uses the same state file unless `-StatePath` is provided. +The default node directory is: + +```text +devnet/local/node/ +``` + +It contains local-only identity, status, inbox, processed, rejected, and stop +files. These files are generated runtime state and must not be committed. + `devnet/local/` is ignored by git. -Private/local testnet acceptance requires a documented genesis/config flow that -can be rerun on a clean machine. The flow must say which files are generated, -which files can be committed as fixtures, and which files are local-only. +Static local-file peers can be smoke-tested with: + +```powershell +npm run flowchain:multi-node:smoke +``` + +This is not LAN networking. It proves two local node processes can exchange or +reconcile deterministic state through configured peer state paths. ## Control Plane diff --git a/docs/FLOWCHAIN_TESTNET_ACCEPTANCE.md b/docs/FLOWCHAIN_TESTNET_ACCEPTANCE.md index ca6af694..c36f7540 100644 --- a/docs/FLOWCHAIN_TESTNET_ACCEPTANCE.md +++ b/docs/FLOWCHAIN_TESTNET_ACCEPTANCE.md @@ -1,8 +1,9 @@ # FlowChain Testnet Acceptance -Status: acceptance matrix for the private/local testnet package. The HQ/Ops -command wrapper layer is implemented for merged surfaces; full native object, -control-plane, and workbench acceptance remains in flight. +Status: acceptance matrix for the private/local testnet package. The chain +runtime now has a long-running local node, local transaction intake, local +test-unit faucet records, and static local-file multi-node reconciliation. +Control-plane query coverage and workbench runtime inspection remain in flight. This document marks every major feature as one of: @@ -21,10 +22,10 @@ This document marks every major feature as one of: | Run devnet tests | Implemented | `cargo test --manifest-path crates/flowmemory-devnet/Cargo.toml`. | | Run service tests | Implemented | `npm test` for merged service packages. | | Run launch candidate gate | Implemented | `npm run launch:candidate`. | -| One-command private testnet aliases | Implemented for merged surfaces | `package.json` now exposes `flowchain:prereq`, `flowchain:init`, `flowchain:start`, `flowchain:stop`, `flowchain:demo`, `flowchain:smoke`, `flowchain:export`, `flowchain:import`, and `workbench:dev`. `control-plane:serve` remains in flight. | +| One-command private testnet aliases | Implemented for chain runtime surfaces | `package.json` now exposes `flowchain:prereq`, `flowchain:init`, `flowchain:start`, `flowchain:node`, `flowchain:node:stop`, `flowchain:node:status`, `flowchain:tx`, `flowchain:faucet`, `flowchain:node:smoke`, `flowchain:multi-node:smoke`, `flowchain:stop`, `flowchain:demo`, `flowchain:smoke`, `flowchain:full-smoke`, `flowchain:export`, `flowchain:import`, and `workbench:dev`. `control-plane:serve` remains in flight. | | Prerequisite check script | Implemented | `infra/scripts/flowchain-check-prereqs.ps1`. | -| Start/stop scripts | Implemented bounded wrappers | `flowchain:start` prepares launch-core fixtures and state summary; `flowchain:stop` records stopped state and can reset ignored local state. Long-running node behavior remains in flight. | -| Full smoke script | Implemented for merged surfaces | `flowchain:smoke` runs service tests, crypto tests/vectors, launch candidate, devnet tests, deterministic replay, dashboard build, hardware fixture, unsafe-claim scan, and export no-secret scan. Native object/control-plane lifecycle remains blocked. | +| Start/stop scripts | Implemented | `flowchain:start` remains a compatibility wrapper; `flowchain:node` starts the long-running runtime; `flowchain:node:stop` and `flowchain:stop` request node shutdown through the stop file. | +| Full smoke script | Implemented for merged surfaces | `flowchain:smoke` runs service tests, crypto tests/vectors, launch candidate, devnet tests, one-node runtime smoke, multi-node runtime smoke, deterministic replay, dashboard build, hardware fixture, unsafe-claim scan, and export no-secret scan. Control-plane query evidence remains blocked on subsystem work. | | Export/import state bundles | Implemented local wrapper | `flowchain:export` writes ignored export files and zip bundle; `flowchain:import` restores local state from a bundle. | | Troubleshooting guide | Implemented | `docs/FLOWCHAIN_TROUBLESHOOTING.md` plus script error messages. | @@ -33,15 +34,16 @@ This document marks every major feature as one of: | Feature | Status | Acceptance condition | | --- | --- | --- | | No-value deterministic devnet | Implemented | Existing Rust devnet remains the single runtime surface. | -| Private/local genesis/config | In flight | Chain agent must document generated config and replay behavior. | -| Single-node local runtime | In flight | Current CLI can init/demo/run blocks and `flowchain:start` gives an obvious bounded start path; long-running node behavior is still missing. | -| Multi-node or LAN notes | Missing | Must be optional and safe, or marked later gated. | +| Private/local genesis/config | Implemented for chain runtime | `init` writes deterministic genesis config and operator key references; node state is under `devnet/local/state.json`; node-local identity/status files are under `devnet/local/node/`. | +| Single-node local runtime | Implemented | `npm run flowchain:node` runs a long-running local node that persists state, ingests inbox transactions, produces interval blocks, writes logs/status, and stops through `flowchain:node:stop`. `npm run flowchain:node:smoke` proves 10+ blocks, transaction inclusion, restart persistence, and export/import. | +| Multi-node or LAN notes | Implemented local-file mode; LAN not exposed | `npm run flowchain:multi-node:smoke` starts two local node processes and proves static local-file peer reconciliation. LAN mode is explicitly not exposed. | | Deterministic block production | Implemented | Current devnet models deterministic blocks and state roots. | | Deterministic replay | Implemented for merged demo | `flowchain:smoke` reruns the current demo twice and compares exported dashboard state roots. Full native object replay remains in flight. | -| Transaction ingestion | In flight | Current devnet supports fixture submission; expanded object ingestion is active work. | -| State export | Implemented | `export-fixtures` exists; full package export/import still needs the package-level smoke path. | -| State import/snapshot restore | Implemented local wrapper | `flowchain:import` restores current devnet state from an exported bundle; richer subsystem snapshots remain future work. | -| Health/status output | In flight | CLI summary exists; control-plane health is active work. | +| Transaction ingestion | Implemented for chain runtime | `submit-tx`, `faucet`, `flowchain:tx`, and `flowchain:faucet` submit locally authorized JSON transactions to node inboxes or direct pending state. | +| Local balance/faucet ledger | Implemented for no-value test units | `FaucetLocalBalance`, `TransferLocalBalance`, `localBalances`, `faucetRecords`, and `balanceTransfers` are included in state roots, blocks, exports, and smoke tests. | +| State export | Implemented | `export-fixtures`, `export-state`, `flowchain:export`, and `flowchain:node:smoke` export runtime state. | +| State import/snapshot restore | Implemented local wrapper | `flowchain:import` restores current devnet state from an exported bundle; `flowchain:node:smoke` proves runtime export/import root equality. | +| Health/status output | Implemented for node/runtime | `node-status` and `flowchain:node:status` expose latest block, state root, pending transactions, local balance counts, persisted node status, and LAN boundary. Control-plane health remains in flight. | ## Native Objects @@ -50,17 +52,18 @@ This document marks every major feature as one of: | Rootfield namespace | Implemented | Existing contracts, launch fixtures, and devnet model support this. | | Root commitment | Implemented | Existing contracts, fixtures, and devnet model support this. | | FlowPulse linkage | Implemented | Launch-core fixtures preserve contract-event semantics. | -| AgentAccount | In flight | Active devnet/crypto work adds local object identity and state. | -| ModelPassport | In flight | Active devnet/crypto work adds local object identity and state. | -| WorkReceipt | In flight | Foundation exists; it must still be part of the full private testnet smoke flow. | +| AgentAccount | Implemented in devnet | `RegisterAgent`, demo, handoff export, and `flowchain:tx` sample transaction cover local agent identity. | +| ModelPassport | Implemented in devnet | `RegisterModelPassport`, demo, handoff export, and `flowchain:tx` sample transaction cover local model provenance. | +| WorkReceipt | Implemented in devnet | Demo and smoke submit work receipts with dependency checks and handoff export. | | ToolReceipt | Missing | Explicit placeholder is acceptable for this package if documented. | | EvalReceipt | Missing | Explicit placeholder is acceptable for this package if documented. | -| ArtifactAvailabilityProof | In flight | Active devnet/crypto/hardware work maps availability objects. | -| VerifierModule | In flight | Active devnet/crypto work adds local verifier identity. | -| VerifierReport | In flight | Existing verifier reports exist; they still must be queryable and workbench-visible in the private testnet package. | -| MemoryCell | In flight | Active devnet/crypto/control-plane work expands local state. | -| Challenge | In flight | Active devnet/crypto/control-plane work adds local challenge shape. | -| FinalityReceipt | In flight | Active devnet/crypto/control-plane work adds local finality shape. | +| ArtifactAvailabilityProof | Implemented in devnet | `MarkArtifactAvailability`, demo, smoke, roots, and handoff export cover local availability records. | +| VerifierModule | Implemented in devnet | `RegisterVerifierModule`, verifier dependency checks, demo, smoke, roots, and handoff export cover local verifier identity. | +| VerifierReport | Implemented in devnet | `SubmitVerifierReport`, accepted/failed status checks, demo, smoke, roots, and handoff export cover local reports. Workbench/query exposure remains subsystem work. | +| MemoryCell | Implemented in devnet | `UpdateMemoryCell` requires an accepted verifier report and updates agent memory root; demo, smoke, roots, and handoff export cover it. | +| Challenge | Implemented in devnet | `OpenChallenge` and `ResolveChallenge` enforce receipt/finality rules; demo, smoke, roots, and handoff export cover them. | +| FinalityReceipt | Implemented in devnet | `FinalizeWorkReceipt` requires accepted receipts with no unresolved challenge; demo, smoke, roots, and handoff export cover it. | +| LocalBalance/FaucetRecord | Implemented in devnet | Local no-value test-unit ledger supports transaction/faucet smoke and is included in roots, blocks, exports, and node status. | | DependencyAtom | Later gated | Keep as placeholder or dependency-root boundary; no SEAL proof claim. | ## Control Plane API @@ -70,7 +73,7 @@ This document marks every major feature as one of: | Local API service | In flight | Extend `services/control-plane/`; do not create a second API surface. | | Health endpoint/method | In flight | Must show local-only status and source health. | | Chain status | In flight | Must include block, object, fixture, and capability counters. | -| Blocks and transactions | Missing | Required for full private testnet inspection. | +| Blocks and transactions | In flight | Devnet state and control-plane handoff include blocks, pending transactions, object maps, and roots; live API methods remain control-plane work. | | Agents and models | In flight | Must read existing devnet/fixture outputs. | | Receipts and artifacts | In flight | Must link memory receipts, work receipts, artifacts, and provenance. | | Verifier reports | In flight | Must expose reports and stable error shapes. | @@ -86,11 +89,11 @@ This document marks every major feature as one of: | --- | --- | --- | | Existing dashboard V0 | Implemented | Fixture-backed app renders V0 Rootflow/Flow Memory and devnet data. | | Local private testnet workbench | In flight | Extend `apps/dashboard/`; do not build a second dashboard. | -| Node health view | Missing | Must show local runtime/control-plane status. | -| Blocks and transactions views | Missing | Must show deterministic local block and transaction state. | -| Agents and models views | Missing | Must show local identity/provenance state. | +| Node health view | In flight | Node status JSON exists; workbench rendering remains dashboard work. | +| Blocks and transactions views | In flight | Devnet handoff includes blocks and pending transactions; workbench rendering remains dashboard work. | +| Agents and models views | In flight | Devnet handoff includes local agents and models; workbench rendering remains dashboard work. | | Receipts, artifacts, reports views | In flight | Existing dashboard has V0 views; needs private testnet completeness. | -| Memory cells, challenges, finality views | Missing | Required for full smoke inspection. | +| Memory cells, challenges, finality views | In flight | Devnet handoff includes these objects; workbench rendering remains dashboard work. | | Provenance/source view | Missing | Required for second-computer debugging. | | Raw JSON view | Implemented | Existing dashboard has a raw JSON view; private testnet data remains part of the workbench extension. | | Loading/empty/error states | Missing | Required before second-computer validation. | @@ -101,7 +104,7 @@ This document marks every major feature as one of: | --- | --- | --- | | Keccak typed hash helpers | Implemented | Existing `crypto/` package and vectors. | | Local object IDs | In flight | Active crypto work expands object IDs and schemas. | -| Signature/envelope policy | In flight | Must cover local operators, agents, verifiers, and hardware signal issuers. | +| Signature/envelope policy | In flight | Devnet records `local-authorized` transaction envelopes for local operators; full crypto vectors remain crypto work. | | Negative vector tests | In flight | Must cover wrong domain, missing signer, zero hash, malformed objects, and replay. | | Local operator key generation/import | Implemented local-only wrapper | `flowchain:init` writes ignored `devnet/local/operator.local.json` or imports a local operator file. Encrypted vault behavior remains missing. | | Encrypted local operator vault | Missing | Preferred target; at minimum document current local key boundary. | @@ -149,19 +152,24 @@ Current wrapper status: - `npm run flowchain:smoke` is the documented command. - It proves the merged launch-core, crypto helpers/vectors, local devnet, - export, dashboard build, hardware fixture, deterministic replay, and - claim/no-secret guardrails. -- It does not yet prove AgentAccount, ModelPassport, native MemoryCell, - Challenge, FinalityReceipt, or control-plane query coverage. Those rows stay - in flight or missing until subsystem PRs land behind the wrapper. + native AgentAccount, ModelPassport, ArtifactAvailabilityProof, + VerifierModule, VerifierReport, MemoryCell, Challenge, FinalityReceipt, + local test-unit faucet records, one-node runtime behavior, static local-file + multi-node reconciliation, export/import, dashboard build, hardware fixture, + deterministic replay, and claim/no-secret guardrails. +- It does not yet prove live control-plane query coverage or workbench runtime + rendering. Those rows stay in flight until subsystem PRs land behind the + wrapper. Required final evidence for the acceptance PR: - Commands run. - Output files generated. - Deterministic root or fixture hash comparison. -- Control-plane query sample. -- Workbench screenshot or test/build evidence. +- Control-plane query sample remains a follow-up until the control-plane + subsystem exposes the runtime handoff through API methods. +- Workbench screenshot or test/build evidence remains a follow-up until the + dashboard subsystem renders the runtime handoff. - `git diff --check`. ## Review Gate diff --git a/docs/LOCAL_DEVNET.md b/docs/LOCAL_DEVNET.md index ccfccafd..ec40a3ef 100644 --- a/docs/LOCAL_DEVNET.md +++ b/docs/LOCAL_DEVNET.md @@ -1,10 +1,10 @@ # FlowMemory Local Devnet -Status: runnable no-value local runtime +Status: runnable no-value local runtime with bounded and long-running node modes The local FlowMemory devnet is a Rust CLI that models FlowMemory appchain-style state transitions without production consensus, tokenomics, bridge assets, public validator onboarding, or mainnet claims. -It is local/no-value only. It has no balances, rewards, staking, gas economics, bridge security, or production deployment behavior. +It is local/no-value only. It has no rewards, staking, gas economics, bridge security, or production deployment behavior. It includes a local test-unit ledger so private/local transactions can be funded and smoke-tested; those records are not tokenomics and have no external value. ## Framework Decision @@ -34,14 +34,22 @@ Windows-first root wrappers: ```powershell npm run flowchain:init npm run flowchain:start +npm run flowchain:node +npm run flowchain:node:status +npm run flowchain:tx +npm run flowchain:faucet npm run flowchain:demo npm run flowchain:export +npm run flowchain:node:stop npm run flowchain:stop +npm run flowchain:node:smoke +npm run flowchain:multi-node:smoke ``` The wrappers call the Rust CLI below and write ignored operator/status/handoff/ -export files under `devnet/local/`. The current runtime is still a -deterministic local CLI, not a long-running node. +export files under `devnet/local/`. `flowchain:start` remains a compatibility +wrapper for launch-core preparation and summary inspection. `flowchain:node` +starts the long-running private/local runtime. Initialize state: @@ -89,6 +97,55 @@ Build a block from pending transactions: cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- run-block ``` +Start a long-running local node: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state devnet/local/state.json --node-dir devnet/local/node node --node-id node:local:alpha --block-ms 1000 +``` + +The node writes: + +```text +devnet/local/node/node-identity.json +devnet/local/node/status.json +devnet/local/node/inbox/ +devnet/local/node/processed/ +devnet/local/node/rejected/ +devnet/local/node/stop +``` + +Stop and inspect the node: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state devnet/local/state.json --node-dir devnet/local/node node-status +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state devnet/local/state.json --node-dir devnet/local/node node-stop +``` + +Submit a local transaction to the node inbox: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state devnet/local/state.json --node-dir devnet/local/node submit-tx --tx-file devnet/local/tx/sample-agent-registration.json --authorized-by local-operator +``` + +Credit a local test-unit account through the faucet transaction path: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state devnet/local/state.json --node-dir devnet/local/node faucet --account local-account:operator --amount 1000 --authorized-by local-operator +``` + +Run one manual node tick: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state devnet/local/state.json --node-dir devnet/local/node tick --node-id node:local:alpha +``` + +Run the bounded node smokes: + +```powershell +npm run flowchain:node:smoke +npm run flowchain:multi-node:smoke +``` + Run a bounded local block-production loop: ```powershell @@ -147,20 +204,22 @@ The demo: 3. Registers a rootfield. 4. Registers a model passport. 5. Registers an agent account. -6. Registers a verifier module identity. -7. Submits an artifact commitment. -8. Marks artifact availability with a local proof/status object. -9. Commits a latest root. -10. Submits a work receipt. -11. Submits an accepted verifier report. -12. Updates a memory cell from the accepted receipt. -13. Opens and resolves a challenge. -14. Finalizes the work receipt. -15. Builds block 1. -16. Creates a Base settlement anchor placeholder with deterministic map roots. -17. Builds block 2. -18. Writes state to `devnet/local/state.json`. -19. Exports dashboard, indexer, verifier, control-plane, config, key-reference, and full-state handoff files to `fixtures/handoff/generated/`. +6. Credits local test units through the faucet record path. +7. Transfers local test units to the demo agent id. +8. Registers a verifier module identity. +9. Submits an artifact commitment. +10. Marks artifact availability with a local proof/status object. +11. Commits a latest root. +12. Submits a work receipt. +13. Submits an accepted verifier report. +14. Updates a memory cell from the accepted receipt. +15. Opens and resolves a challenge. +16. Finalizes the work receipt. +17. Builds block 1. +18. Creates a Base settlement anchor placeholder with deterministic map roots. +19. Builds block 2. +20. Writes state to `devnet/local/state.json`. +21. Exports dashboard, indexer, verifier, control-plane, config, key-reference, and full-state handoff files to `fixtures/handoff/generated/`. ## State Model @@ -168,6 +227,9 @@ The prototype stores: - `config` - `operatorKeyReferences` +- `localBalances` +- `faucetRecords` +- `balanceTransfers` - `rootfields` - `agentAccounts` - `modelPassports` @@ -185,13 +247,15 @@ The prototype stores: - `blocks` - `pendingTxs` -There are no token balances and no gas accounting. +The local balance maps are private/local test-unit records only. There are no token balances, rewards, fees, staking, or gas accounting. ## Transaction Types Supported local transactions: - `RegisterRootfield` +- `FaucetLocalBalance` +- `TransferLocalBalance` - `RegisterAgent` - `RegisterModelPassport` - `CommitRoot` @@ -210,7 +274,9 @@ Supported local transactions: ## Local Lifecycle Rules -- Agent and model records are identity/provenance records only; they do not hold balances. +- Agent and model records are identity/provenance records only. Local test-unit balances live in the separate local balance ledger and do not imply tokenomics. +- Faucet records can credit local test units to an account id for smoke testing. +- Local balance transfers require sufficient local test-unit balance and produce deterministic transfer records. - Work receipts must reference an existing artifact commitment in the same rootfield. - Verifier reports must reference an existing active verifier module and an existing receipt in the same rootfield. - Memory cells can be created or updated only from an existing work receipt with an accepted local verifier report. @@ -234,7 +300,7 @@ Each block has: The devnet uses deterministic logical time and canonical JSON with Keccak-256. Tests prove the same inputs produce the same state root and block hash. -`inspect-state --summary`, exported handoff files, and Base anchor placeholders include deterministic roots for the local maps, including operator key references, agent accounts, model passports, memory cells, challenges, finality receipts, artifact availability proofs, verifier modules, work receipts, and verifier reports. +`inspect-state --summary`, exported handoff files, and Base anchor placeholders include deterministic roots for the local maps, including operator key references, local balances, faucet records, balance transfers, agent accounts, model passports, memory cells, challenges, finality receipts, artifact availability proofs, verifier modules, work receipts, and verifier reports. ## Persistence @@ -262,6 +328,34 @@ The generated dashboard, indexer, verifier, and state outputs include the expand The control-plane handoff contains the current chain id, latest block, blocks, pending transactions, object maps, deterministic map roots, genesis config, and operator key references. It is intended for local services to consume without reading ignored `devnet/local/` files. +Node identity and status files are local operator files under `devnet/local/node/`. +They are not committed handoff fixtures, but they provide the local node id, +process id, state path, latest block, state root, inbox counts, and LAN boundary +for second-computer debugging. + +## Static Local Peers + +LAN mode is not exposed in this package. Multi-node smoke uses static local-file +peer state paths. A peer config has this shape: + +```json +{ + "schema": "flowmemory.local_devnet.static_peers.v0", + "nodeId": "node:local:a", + "peers": [ + { + "nodeId": "node:local:b", + "statePath": "devnet/local/node-b/state.json" + } + ] +} +``` + +When a node sees a peer state file for the same chain with a longer height, or +an equal-height deterministic lower state root, it adopts that state. This is a +local deterministic reconciliation model for second-computer validation, not a +production networking or consensus protocol. + ## Non-Goals - No production consensus. diff --git a/infra/scripts/flowchain-common.ps1 b/infra/scripts/flowchain-common.ps1 index bb7481d4..83f5ef18 100644 --- a/infra/scripts/flowchain-common.ps1 +++ b/infra/scripts/flowchain-common.ps1 @@ -78,6 +78,21 @@ function Invoke-FlowChainCommand { } } +function Join-FlowChainProcessArguments { + param( + [string[]] $ArgumentList = @() + ) + + return ($ArgumentList | ForEach-Object { + if ($_.IndexOfAny([char[]] @(" ", "`t", '"')) -ge 0) { + '"' + ($_.Replace('"', '\"')) + '"' + } + else { + $_ + } + }) -join " " +} + function Set-FlowChainCargoTargetDir { param( [Parameter(Mandatory = $true)] diff --git a/infra/scripts/flowchain-faucet.ps1 b/infra/scripts/flowchain-faucet.ps1 new file mode 100644 index 00000000..62c89cc5 --- /dev/null +++ b/infra/scripts/flowchain-faucet.ps1 @@ -0,0 +1,45 @@ +param( + [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node", + [string] $Account = "local-account:operator", + [UInt64] $Amount = 1000, + [string] $Reason = "local-private-testnet-faucet", + [string] $AuthorizedBy = "local-operator", + [switch] $Direct +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot | Out-Null +$stateFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $StatePath) +$nodeFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $NodeDir) + +$arguments = @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateFullPath, + "--node-dir", + $nodeFullDir, + "faucet", + "--account", + $Account, + "--amount", + "$Amount", + "--reason", + $Reason, + "--authorized-by", + $AuthorizedBy +) + +if ($Direct) { + $arguments += "--direct" +} + +Invoke-FlowChainCommand -Label "Submit FlowChain local faucet transaction" -FilePath "cargo" -ArgumentList $arguments diff --git a/infra/scripts/flowchain-multi-node-smoke.ps1 b/infra/scripts/flowchain-multi-node-smoke.ps1 new file mode 100644 index 00000000..e18b59b3 --- /dev/null +++ b/infra/scripts/flowchain-multi-node-smoke.ps1 @@ -0,0 +1,166 @@ +param( + [string] $SmokeDir = "devnet/local/multi-node-smoke" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot | Out-Null +$smokeFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $SmokeDir) + +if (Test-Path -LiteralPath $smokeFullDir) { + Remove-Item -LiteralPath $smokeFullDir -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $smokeFullDir | Out-Null + +$stateA = Join-Path $smokeFullDir "node-a-state.json" +$stateB = Join-Path $smokeFullDir "node-b-state.json" +$nodeA = Join-Path $smokeFullDir "node-a" +$nodeB = Join-Path $smokeFullDir "node-b" +$peerA = Join-Path $smokeFullDir "node-a-peers.json" +$peerB = Join-Path $smokeFullDir "node-b-peers.json" +$stdoutA = Join-Path $smokeFullDir "node-a.stdout.jsonl" +$stderrA = Join-Path $smokeFullDir "node-a.stderr.log" +$stdoutB = Join-Path $smokeFullDir "node-b.stdout.jsonl" +$stderrB = Join-Path $smokeFullDir "node-b.stderr.log" + +Write-FlowChainJson -Path $peerA -Value ([ordered]@{ + schema = "flowmemory.local_devnet.static_peers.v0" + nodeId = "node:smoke:a" + peers = @( + [ordered]@{ + nodeId = "node:smoke:b" + statePath = $stateB + } + ) +}) + +Write-FlowChainJson -Path $peerB -Value ([ordered]@{ + schema = "flowmemory.local_devnet.static_peers.v0" + nodeId = "node:smoke:b" + peers = @( + [ordered]@{ + nodeId = "node:smoke:a" + statePath = $stateA + } + ) +}) + +$argsA = @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateA, + "--node-dir", + $nodeA, + "node", + "--node-id", + "node:smoke:a", + "--block-ms", + "250", + "--max-blocks", + "12", + "--peer-config", + $peerA +) + +$processA = Start-Process -FilePath "cargo" -ArgumentList (Join-FlowChainProcessArguments -ArgumentList $argsA) -WorkingDirectory $repoRoot -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutA -RedirectStandardError $stderrA +Start-Sleep -Milliseconds 500 + +Invoke-FlowChainCommand -Label "Submit locally authorized transaction to node A" -FilePath "cargo" -ArgumentList @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateA, + "--node-dir", + $nodeA, + "faucet", + "--account", + "local-account:multi-node", + "--amount", + "77", + "--reason", + "multi-node-smoke", + "--authorized-by", + "local-smoke-operator" +) + +$argsB = @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateB, + "--node-dir", + $nodeB, + "node", + "--node-id", + "node:smoke:b", + "--block-ms", + "250", + "--max-blocks", + "12", + "--peer-config", + $peerB +) + +$processB = Start-Process -FilePath "cargo" -ArgumentList (Join-FlowChainProcessArguments -ArgumentList $argsB) -WorkingDirectory $repoRoot -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutB -RedirectStandardError $stderrB + +foreach ($process in @($processA, $processB)) { + if (-not $process.WaitForExit(45000)) { + & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $stateA --node-dir $nodeA node-stop | Out-Null + & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $stateB --node-dir $nodeB node-stop | Out-Null + $process.Kill() + throw "Multi-node smoke runtime did not stop after bounded run." + } + $process.Refresh() + if ($null -ne $process.ExitCode -and $process.ExitCode -ne 0) { + throw "Multi-node smoke process $($process.Id) failed with exit code $($process.ExitCode)." + } +} + +$summaryA = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $stateA --node-dir $nodeA node-status | ConvertFrom-Json +if ($LASTEXITCODE -ne 0) { + throw "node-status failed for node A." +} +$summaryB = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $stateB --node-dir $nodeB node-status | ConvertFrom-Json +if ($LASTEXITCODE -ne 0) { + throw "node-status failed for node B." +} + +if ($summaryA.state.localBalances -lt 1) { + throw "Node A did not include the local balance transaction." +} +if ($summaryB.state.localBalances -lt 1) { + throw "Node B did not reconcile the local balance transaction from node A." +} +if ($summaryA.state.stateRoot -ne $summaryB.state.stateRoot) { + throw "Multi-node deterministic reconciliation failed. Node A root $($summaryA.state.stateRoot), node B root $($summaryB.state.stateRoot)." +} + +$reportPath = Join-Path $smokeFullDir "multi-node-smoke-report.json" +$report = [ordered]@{ + schema = "flowchain.private_testnet.multi_node_smoke.v0" + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + nodeAState = $stateA + nodeBState = $stateB + nodeABlocks = $summaryA.state.blocks + nodeBBlocks = $summaryB.state.blocks + reconciledStateRoot = $summaryA.state.stateRoot + staticPeerConfig = @($peerA, $peerB) + lanMode = "not exposed; this smoke proves two local processes reconcile through static local-file peer state paths" +} +Write-FlowChainJson -Path $reportPath -Value $report + +Write-Host "" +Write-Host "FlowChain multi-node local-file smoke passed." +Write-Host "Reconciled state root: $($summaryA.state.stateRoot)" +Write-Host "Report: $reportPath" diff --git a/infra/scripts/flowchain-node-smoke.ps1 b/infra/scripts/flowchain-node-smoke.ps1 new file mode 100644 index 00000000..2004e8f4 --- /dev/null +++ b/infra/scripts/flowchain-node-smoke.ps1 @@ -0,0 +1,173 @@ +param( + [string] $SmokeDir = "devnet/local/node-smoke" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot | Out-Null +$smokeFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $SmokeDir) + +if (Test-Path -LiteralPath $smokeFullDir) { + Remove-Item -LiteralPath $smokeFullDir -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $smokeFullDir | Out-Null + +$statePath = Join-Path $smokeFullDir "state.json" +$nodeDir = Join-Path $smokeFullDir "node" +$snapshotPath = Join-Path $smokeFullDir "state-snapshot.json" +$importedStatePath = Join-Path $smokeFullDir "imported-state.json" +$stdoutPath = Join-Path $smokeFullDir "node.stdout.jsonl" +$stderrPath = Join-Path $smokeFullDir "node.stderr.log" + +$nodeArgs = @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $statePath, + "--node-dir", + $nodeDir, + "node", + "--node-id", + "node:smoke:one", + "--block-ms", + "250", + "--max-blocks", + "10" +) + +$process = Start-Process -FilePath "cargo" -ArgumentList (Join-FlowChainProcessArguments -ArgumentList $nodeArgs) -WorkingDirectory $repoRoot -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath +Start-Sleep -Milliseconds 700 + +Invoke-FlowChainCommand -Label "Submit locally authorized faucet transaction to running node" -FilePath "cargo" -ArgumentList @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $statePath, + "--node-dir", + $nodeDir, + "faucet", + "--account", + "local-account:node-smoke", + "--amount", + "42", + "--reason", + "one-node-smoke", + "--authorized-by", + "local-smoke-operator" +) + +if (-not $process.WaitForExit(30000)) { + & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $statePath --node-dir $nodeDir node-stop | Out-Null + $process.Kill() + throw "One-node smoke runtime did not stop after bounded 10-block run." +} +$process.Refresh() + +if ($null -ne $process.ExitCode -and $process.ExitCode -ne 0) { + $stderr = Get-Content -Raw -LiteralPath $stderrPath + throw "One-node smoke runtime failed with exit code $($process.ExitCode): $stderr" +} + +$summary = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $statePath --node-dir $nodeDir node-status | ConvertFrom-Json +if ($LASTEXITCODE -ne 0) { + throw "node-status failed after one-node smoke." +} +if ($summary.state.blocks -lt 10) { + throw "Expected one-node smoke to produce at least 10 blocks, got $($summary.state.blocks)." +} +if ($summary.state.localBalances -lt 1 -or $summary.state.faucetRecords -lt 1) { + throw "Expected locally authorized faucet transaction to be included in one-node smoke." +} + +Invoke-FlowChainCommand -Label "Restart node for persistence check" -FilePath "cargo" -ArgumentList @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $statePath, + "--node-dir", + $nodeDir, + "node", + "--node-id", + "node:smoke:one", + "--block-ms", + "50", + "--max-blocks", + "1" +) + +$restartedSummary = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $statePath --node-dir $nodeDir node-status | ConvertFrom-Json +if ($LASTEXITCODE -ne 0) { + throw "node-status failed after persistence restart." +} +if ($restartedSummary.state.blocks -lt 11) { + throw "Expected restart to preserve state and add a block." +} +if ($restartedSummary.state.localBalances -lt 1 -or $restartedSummary.state.faucetRecords -lt 1) { + throw "Expected local balance state to survive restart." +} + +Invoke-FlowChainCommand -Label "Export runtime state snapshot" -FilePath "cargo" -ArgumentList @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $statePath, + "export-state", + "--out", + $snapshotPath +) + +Invoke-FlowChainCommand -Label "Import runtime state snapshot" -FilePath "cargo" -ArgumentList @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $importedStatePath, + "import-state", + "--from", + $snapshotPath +) + +$original = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $statePath inspect-state --summary | ConvertFrom-Json +if ($LASTEXITCODE -ne 0) { + throw "Failed to inspect original smoke state." +} +$imported = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $importedStatePath inspect-state --summary | ConvertFrom-Json +if ($LASTEXITCODE -ne 0) { + throw "Failed to inspect imported smoke state." +} +if ($original.stateRoot -ne $imported.stateRoot) { + throw "Export/import state roots differ: $($original.stateRoot) vs $($imported.stateRoot)" +} + +$reportPath = Join-Path $smokeFullDir "one-node-smoke-report.json" +$report = [ordered]@{ + schema = "flowchain.private_testnet.one_node_smoke.v0" + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + statePath = $statePath + nodeDir = $nodeDir + blocksAfterRestart = $restartedSummary.state.blocks + locallyAuthorizedTxIncluded = $true + stateSurvivedRestart = $true + exportImportStateRoot = $original.stateRoot + lanMode = "not exposed; static local-file peers only" +} +Write-FlowChainJson -Path $reportPath -Value $report + +Write-Host "" +Write-Host "FlowChain one-node runtime smoke passed." +Write-Host "Blocks after restart: $($restartedSummary.state.blocks)" +Write-Host "State root: $($original.stateRoot)" +Write-Host "Report: $reportPath" diff --git a/infra/scripts/flowchain-node-status.ps1 b/infra/scripts/flowchain-node-status.ps1 new file mode 100644 index 00000000..f8f2b212 --- /dev/null +++ b/infra/scripts/flowchain-node-status.ps1 @@ -0,0 +1,25 @@ +param( + [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node" +) +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot | Out-Null +$stateFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $StatePath) +$nodeFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $NodeDir) + +Invoke-FlowChainCommand -Label "Inspect FlowChain node status" -FilePath "cargo" -ArgumentList @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateFullPath, + "--node-dir", + $nodeFullDir, + "node-status" +) diff --git a/infra/scripts/flowchain-node-stop.ps1 b/infra/scripts/flowchain-node-stop.ps1 new file mode 100644 index 00000000..537df2aa --- /dev/null +++ b/infra/scripts/flowchain-node-stop.ps1 @@ -0,0 +1,25 @@ +param( + [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node" +) +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot | Out-Null +$stateFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $StatePath) +$nodeFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $NodeDir) + +Invoke-FlowChainCommand -Label "Request FlowChain node stop" -FilePath "cargo" -ArgumentList @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateFullPath, + "--node-dir", + $nodeFullDir, + "node-stop" +) diff --git a/infra/scripts/flowchain-node.ps1 b/infra/scripts/flowchain-node.ps1 new file mode 100644 index 00000000..f3e60655 --- /dev/null +++ b/infra/scripts/flowchain-node.ps1 @@ -0,0 +1,45 @@ +param( + [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node", + [string] $NodeId = "node:local:alpha", + [int] $BlockMs = 1000, + [int] $MaxBlocks = 0, + [string] $PeerConfig = "" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot | Out-Null +$stateFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $StatePath) +$nodeFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $NodeDir) + +$arguments = @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateFullPath, + "--node-dir", + $nodeFullDir, + "node", + "--node-id", + $NodeId, + "--block-ms", + "$BlockMs" +) + +if ($MaxBlocks -gt 0) { + $arguments += @("--max-blocks", "$MaxBlocks") +} + +if (-not [string]::IsNullOrWhiteSpace($PeerConfig)) { + $peerConfigFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $PeerConfig) + $arguments += @("--peer-config", $peerConfigFullPath) +} + +Invoke-FlowChainCommand -Label "Run FlowChain private/local node" -FilePath "cargo" -ArgumentList $arguments diff --git a/infra/scripts/flowchain-smoke.ps1 b/infra/scripts/flowchain-smoke.ps1 index b4972d87..372a6b46 100644 --- a/infra/scripts/flowchain-smoke.ps1 +++ b/infra/scripts/flowchain-smoke.ps1 @@ -27,6 +27,20 @@ Invoke-FlowChainCommand -Label "Run crypto tests" -FilePath "npm" -ArgumentList Invoke-FlowChainCommand -Label "Validate crypto vectors" -FilePath "npm" -ArgumentList @("run", "validate:vectors", "--prefix", "crypto") Invoke-FlowChainCommand -Label "Run launch candidate gate" -FilePath "npm" -ArgumentList @("run", "launch:candidate") Invoke-FlowChainCommand -Label "Run devnet tests" -FilePath "cargo" -ArgumentList @("test", "--manifest-path", "crates/flowmemory-devnet/Cargo.toml") +Invoke-FlowChainCommand -Label "Run one-node runtime smoke" -FilePath "powershell" -ArgumentList @( + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "infra/scripts/flowchain-node-smoke.ps1" +) +Invoke-FlowChainCommand -Label "Run multi-node runtime smoke" -FilePath "powershell" -ArgumentList @( + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "infra/scripts/flowchain-multi-node-smoke.ps1" +) $runAState = Join-Path $smokeRoot "run-a/state.json" $runAOut = Join-Path $smokeRoot "run-a/export" @@ -92,6 +106,8 @@ $report = [ordered]@{ runBExport = $runBOut launchCandidate = "passed" devnetTests = "passed" + oneNodeRuntimeSmoke = "passed" + multiNodeRuntimeSmoke = "passed" serviceTests = "passed" cryptoTests = "passed" cryptoVectors = "passed" @@ -102,21 +118,24 @@ $report = [ordered]@{ "rootfield namespace", "root commitment", "artifact commitment", + "local balance faucet record", + "locally authorized transaction intake", "work receipt", "verifier report", - "local finality placeholder export", - "launch-core Flow Memory objects" - ) - blockedLifecycleCoverage = @( - "AgentAccount", - "ModelPassport", "ArtifactAvailabilityProof as native object", "VerifierModule", "MemoryCell", "Challenge", "FinalityReceipt as native object", + "long-running node mode with bounded smoke", + "static local-file peer reconciliation", + "local finality placeholder export", + "launch-core Flow Memory objects" + ) + blockedLifecycleCoverage = @( "control-plane query evidence" ) + lanMode = "not exposed; multi-node smoke uses static local-file peer state paths" } Write-FlowChainJson -Path $reportPath -Value $report diff --git a/infra/scripts/flowchain-start.ps1 b/infra/scripts/flowchain-start.ps1 index 4bafab1f..ea04ecb2 100644 --- a/infra/scripts/flowchain-start.ps1 +++ b/infra/scripts/flowchain-start.ps1 @@ -42,15 +42,17 @@ $status = [ordered]@{ startedAt = (Get-Date).ToUniversalTime().ToString("o") statePath = $stateFullPath runtimeMode = "bounded-local-cli" - longRunningNode = $false + longRunningNode = "available through npm run flowchain:node" launchCoreGenerated = -not $SkipLaunchCore workbenchCommand = "npm run workbench:dev" smokeCommand = "npm run flowchain:smoke" - note = "Current merged runtime is a deterministic local CLI, not a daemon. Keep this file as operator state for the second-computer package." + nodeCommand = "npm run flowchain:node" + note = "This compatibility wrapper prepares local state and launch-core fixtures. Use npm run flowchain:node for the long-running private/local runtime." } Write-FlowChainJson -Path $statusPath -Value $status Write-Host "" Write-Host "FlowChain private/local stack is ready in bounded local CLI mode." Write-Host "Next command for a transaction demo: npm run flowchain:demo" +Write-Host "Long-running node command: npm run flowchain:node" Write-Host "Workbench command: npm run workbench:dev" diff --git a/infra/scripts/flowchain-stop.ps1 b/infra/scripts/flowchain-stop.ps1 index 980b8a56..841a216b 100644 --- a/infra/scripts/flowchain-stop.ps1 +++ b/infra/scripts/flowchain-stop.ps1 @@ -1,5 +1,6 @@ param( [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node", [switch] $ResetLocalState ) @@ -11,8 +12,21 @@ Set-StrictMode -Version Latest $repoRoot = Set-FlowChainRepoRoot Set-FlowChainCargoTargetDir -RepoRoot $repoRoot | Out-Null $stateFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $StatePath) +$nodeFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $NodeDir) $statusPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path "devnet/local/flowchain-stack-status.json") +Invoke-FlowChainCommand -Label "Request long-running node stop" -FilePath "cargo" -ArgumentList @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateFullPath, + "--node-dir", + $nodeFullDir, + "node-stop" +) + if ($ResetLocalState) { Invoke-FlowChainCommand -Label "Reset local devnet state" -FilePath "cargo" -ArgumentList @( "run", @@ -30,8 +44,10 @@ $status = [ordered]@{ status = "stopped" stoppedAt = (Get-Date).ToUniversalTime().ToString("o") statePath = $stateFullPath + nodeDir = $nodeFullDir + nodeStopRequested = $true resetLocalState = [bool] $ResetLocalState - note = "No long-running private/local node process is merged yet. Stop records operator state and can reset the ignored local devnet state when explicitly requested." + note = "Stop records operator state, requests the long-running local node to stop through its stop file, and can reset ignored local devnet state when explicitly requested." } Write-FlowChainJson -Path $statusPath -Value $status diff --git a/infra/scripts/flowchain-tx.ps1 b/infra/scripts/flowchain-tx.ps1 new file mode 100644 index 00000000..3ffcb4c9 --- /dev/null +++ b/infra/scripts/flowchain-tx.ps1 @@ -0,0 +1,69 @@ +param( + [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node", + [string] $TxFile = "", + [string] $AuthorizedBy = "local-operator", + [switch] $Direct +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot | Out-Null +$stateFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $StatePath) +$nodeFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $NodeDir) + +if ([string]::IsNullOrWhiteSpace($TxFile)) { + $txDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path "devnet/local/tx") + New-Item -ItemType Directory -Force -Path $txDir | Out-Null + $txFullPath = Join-Path $txDir "sample-agent-registration.json" + $sampleTx = [ordered]@{ + schema = "flowmemory.local_devnet.sample_tx.v0" + txs = @( + [ordered]@{ + type = "RegisterModelPassport" + modelPassportId = "model:local-cli:sample" + issuer = "operator:local-cli" + modelFamily = "local-cli-sample" + modelHash = "0x1111111111111111111111111111111111111111111111111111111111111111" + metadataHash = "0x2222222222222222222222222222222222222222222222222222222222222222" + }, + [ordered]@{ + type = "RegisterAgent" + agentId = "agent:local-cli:sample" + controller = "operator:local-cli" + modelPassportId = "model:local-cli:sample" + metadataHash = "0x3333333333333333333333333333333333333333333333333333333333333333" + } + ) + } + Write-FlowChainJson -Path $txFullPath -Value $sampleTx +} +else { + $txFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $TxFile) +} + +$arguments = @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateFullPath, + "--node-dir", + $nodeFullDir, + "submit-tx", + "--tx-file", + $txFullPath, + "--authorized-by", + $AuthorizedBy +) + +if ($Direct) { + $arguments += "--direct" +} + +Invoke-FlowChainCommand -Label "Submit FlowChain local transaction" -FilePath "cargo" -ArgumentList $arguments diff --git a/package.json b/package.json index d8cb0136..04647aca 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,16 @@ "flowchain:init": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-init.ps1", "flowchain:start": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-start.ps1", "flowchain:stop": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-stop.ps1", + "flowchain:node": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-node.ps1", + "flowchain:node:stop": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-node-stop.ps1", + "flowchain:node:status": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-node-status.ps1", + "flowchain:tx": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-tx.ps1", + "flowchain:faucet": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-faucet.ps1", + "flowchain:node:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-node-smoke.ps1", + "flowchain:multi-node:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-multi-node-smoke.ps1", "flowchain:demo": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-demo.ps1", "flowchain:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-smoke.ps1", + "flowchain:full-smoke": "npm run flowchain:smoke", "flowchain:export": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-export.ps1", "flowchain:import": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-import.ps1", "workbench:dev": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-workbench.ps1",