diff --git a/crates/flowmemory-devnet/src/cli.rs b/crates/flowmemory-devnet/src/cli.rs index d584737a..be02335d 100644 --- a/crates/flowmemory-devnet/src/cli.rs +++ b/crates/flowmemory-devnet/src/cli.rs @@ -1,9 +1,9 @@ use crate::hash::{hash_json, normalize_value}; use crate::model::{ FLOWPULSE_TOPIC0, ImportedFlowPulseObservation, ImportedVerifierReport, Transaction, - build_block, demo_transactions, genesis_state, queue_transaction, state_root, + build_block, demo_transactions, genesis_state, queue_transaction, state_map_roots, state_root, }; -use crate::storage::{default_state_path, load_or_genesis, reset_state, save_state}; +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_json::Value; @@ -21,11 +21,14 @@ pub struct Cli { pub enum Command { Init, ResetLocal, - RunBlock, + 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 }, } pub fn run_cli() -> Result<()> { @@ -63,26 +66,42 @@ fn parse_args(args: Vec) -> Result { let command = match command.as_str() { "init" => Command::Init, "reset-local" => Command::ResetLocal, - "run-block" => Command::RunBlock, + "run-block" => Command::Start { blocks: 1 }, + "start" | "run" => Command::Start { + blocks: option_u64(&positional[1..], "--blocks")?.unwrap_or(1), + }, "submit-fixture" => { let fixture = option_value(&positional[1..], "--fixture")?; Command::SubmitFixture { fixture: PathBuf::from(fixture), } } - "inspect-state" => Command::InspectState { + "inspect" | "inspect-state" => Command::InspectState { summary: positional.iter().any(|arg| arg == "--summary"), }, - "export-fixtures" => Command::ExportFixtures { + "export" | "export-fixtures" => Command::ExportFixtures { out_dir: option_value(&positional[1..], "--out-dir") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("fixtures/handoff/generated")), }, + "export-state" => Command::ExportState { + out: option_value(&positional[1..], "--out") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("fixtures/handoff/generated/state.json")), + }, + "import-state" => Command::ImportState { + from: PathBuf::from(option_value(&positional[1..], "--from")?), + }, "demo" => Command::Demo { out_dir: option_value(&positional[1..], "--out-dir") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("fixtures/handoff/generated")), }, + "smoke" => Command::Smoke { + out_dir: option_value(&positional[1..], "--out-dir") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("fixtures/handoff/generated")), + }, unknown => return Err(anyhow!("unknown command '{unknown}'")), }; @@ -99,9 +118,22 @@ fn option_value(args: &[String], name: &str) -> Result { .ok_or_else(|| anyhow!("{name} requires a value")) } +fn option_u64(args: &[String], name: &str) -> Result> { + let Some(index) = args.iter().position(|arg| arg == name) else { + return Ok(None); + }; + let value = args + .get(index + 1) + .ok_or_else(|| anyhow!("{name} requires a value"))?; + value + .parse::() + .map(Some) + .with_context(|| format!("{name} must be a positive integer")) +} + fn print_help() { println!( - "flowmemory-devnet --state \n\nCommands:\n init\n reset-local\n run-block\n submit-fixture --fixture \n inspect-state [--summary]\n export-fixtures [--out-dir ]\n demo [--out-dir ]\n" + "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" ); } @@ -110,17 +142,19 @@ fn run(cli: Cli) -> Result<()> { Command::Init => { let state = genesis_state(); save_state(&cli.state, &state)?; + write_runtime_boundary_files(&cli.state, &state)?; print_json(&StateSummary::from_state(&state))?; } Command::ResetLocal => { let state = reset_state(&cli.state)?; + write_runtime_boundary_files(&cli.state, &state)?; print_json(&StateSummary::from_state(&state))?; } - Command::RunBlock => { + Command::Start { blocks } => { let mut state = load_or_genesis(&cli.state)?; - let block = build_block(&mut state); + let produced = build_blocks(&mut state, blocks)?; save_state(&cli.state, &state)?; - print_json(&block)?; + print_json(&RunSummary::from_blocks(&state, produced))?; } Command::SubmitFixture { fixture } => { let mut state = load_or_genesis(&cli.state)?; @@ -145,35 +179,88 @@ fn run(cli: Cli) -> Result<()> { export_handoff(&state, &out_dir)?; print_json(&ExportSummary::from_state(&state, out_dir))?; } - Command::Demo { out_dir } => { - let mut state = genesis_state(); - for tx in demo_transactions() { - queue_transaction(&mut state, tx); - } - let first = build_block(&mut state); - let appchain_chain_id = state.chain_id.clone(); - queue_transaction( - &mut state, - Transaction::AnchorBatchToBasePlaceholder { - appchain_chain_id, - finality_status: "local-placeholder".to_string(), - }, - ); - let second = build_block(&mut state); + Command::ExportState { out } => { + let state = load_or_genesis(&cli.state)?; + write_json(out.clone(), &state)?; + print_json(&ExportStateSummary::from_state(&state, out))?; + } + Command::ImportState { from } => { + let state = load_state(&from)?; save_state(&cli.state, &state)?; - export_handoff(&state, &out_dir)?; - print_json(&DemoSummary { - state_path: cli.state, - first_block_hash: first.block_hash, - second_block_hash: second.block_hash, - state_root: state_root(&state), + write_runtime_boundary_files(&cli.state, &state)?; + print_json(&ImportStateSummary::from_state(&state, from, cli.state))?; + } + Command::Demo { out_dir } => { + let demo = build_demo_state(); + save_state(&cli.state, &demo.state)?; + write_runtime_boundary_files(&cli.state, &demo.state)?; + export_handoff(&demo.state, &out_dir)?; + print_json(&DemoSummary::from_demo(cli.state, out_dir, &demo))?; + } + Command::Smoke { out_dir } => { + let first = build_demo_state(); + let second = build_demo_state(); + let deterministic_replay = first.first_block_hash == second.first_block_hash + && first.second_block_hash == second.second_block_hash + && state_root(&first.state) == state_root(&second.state) + && state_map_roots(&first.state) == state_map_roots(&second.state); + save_state(&cli.state, &first.state)?; + write_runtime_boundary_files(&cli.state, &first.state)?; + export_handoff(&first.state, &out_dir)?; + print_json(&SmokeSummary::from_demo( + cli.state, out_dir, - })?; + &first, + deterministic_replay, + ))?; } } Ok(()) } +fn build_blocks( + state: &mut crate::model::ChainState, + blocks: u64, +) -> Result> { + if blocks == 0 { + return Err(anyhow!("--blocks must be greater than zero")); + } + let mut produced = Vec::with_capacity(blocks as usize); + for _ in 0..blocks { + produced.push(build_block(state)); + } + Ok(produced) +} + +struct DemoRun { + state: crate::model::ChainState, + first_block_hash: String, + second_block_hash: String, +} + +fn build_demo_state() -> DemoRun { + let mut state = genesis_state(); + for tx in demo_transactions() { + queue_transaction(&mut state, tx); + } + let first = build_block(&mut state); + let appchain_chain_id = state.chain_id.clone(); + queue_transaction( + &mut state, + Transaction::AnchorBatchToBasePlaceholder { + appchain_chain_id, + finality_status: "local-placeholder".to_string(), + }, + ); + let second = build_block(&mut state); + + DemoRun { + state, + first_block_hash: first.block_hash, + second_block_hash: second.block_hash, + } +} + fn transactions_from_fixture(path: &Path) -> Result> { let body = fs::read_to_string(path) .with_context(|| format!("failed to read fixture {}", path.display()))?; @@ -278,13 +365,24 @@ fn verifier_report_from_fixture(value: &Value) -> Result fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<()> { fs::create_dir_all(out_dir) .with_context(|| format!("failed to create handoff directory {}", out_dir.display()))?; + let map_roots = state_map_roots(state); let dashboard = serde_json::json!({ "schema": "flowmemory.dashboard_state.local_devnet.v0", + "genesisConfig": state.config, + "operatorKeyReferences": state.operator_key_references, "stateRoot": state_root(state), + "mapRoots": map_roots, "blockHeight": state.blocks.len(), "rootfields": state.rootfields, + "agentAccounts": state.agent_accounts, + "modelPassports": state.model_passports, + "memoryCells": state.memory_cells, + "challenges": state.challenges, + "finalityReceipts": state.finality_receipts, "artifactCommitments": state.artifact_commitments, + "artifactAvailabilityProofs": state.artifact_availability_proofs, + "verifierModules": state.verifier_modules, "workReceipts": state.work_receipts, "verifierReports": state.verifier_reports, "baseAnchors": state.base_anchors, @@ -292,26 +390,83 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() let indexer = serde_json::json!({ "schema": "flowmemory.indexer_handoff.local_devnet.v0", + "genesisConfig": state.config, "importedObservations": state.imported_observations, + "operatorKeyReferences": state.operator_key_references, + "agentAccounts": state.agent_accounts, + "memoryCells": state.memory_cells, + "challenges": state.challenges, + "finalityReceipts": state.finality_receipts, + "artifactAvailabilityProofs": state.artifact_availability_proofs, "blocks": state.blocks, + "mapRoots": state_map_roots(state), "stateRoot": state_root(state), }); let verifier = serde_json::json!({ "schema": "flowmemory.verifier_handoff.local_devnet.v0", + "genesisConfig": state.config, + "operatorKeyReferences": state.operator_key_references, + "verifierModules": state.verifier_modules, "workReceipts": state.work_receipts, "verifierReports": state.verifier_reports, + "challenges": state.challenges, + "finalityReceipts": state.finality_receipts, + "artifactAvailabilityProofs": state.artifact_availability_proofs, "importedVerifierReports": state.imported_verifier_reports, + "mapRoots": state_map_roots(state), "stateRoot": state_root(state), }); + let control_plane = serde_json::json!({ + "schema": "flowmemory.control_plane_handoff.local_devnet.v0", + "genesisConfig": state.config, + "operatorKeyReferences": state.operator_key_references, + "chainId": state.chain_id, + "stateRoot": state_root(state), + "mapRoots": state_map_roots(state), + "latestBlock": state.blocks.last(), + "blocks": state.blocks, + "pendingTxs": state.pending_txs, + "objects": { + "rootfields": state.rootfields, + "agentAccounts": state.agent_accounts, + "modelPassports": state.model_passports, + "memoryCells": state.memory_cells, + "challenges": state.challenges, + "finalityReceipts": state.finality_receipts, + "artifactCommitments": state.artifact_commitments, + "artifactAvailabilityProofs": state.artifact_availability_proofs, + "verifierModules": state.verifier_modules, + "workReceipts": state.work_receipts, + "verifierReports": state.verifier_reports, + "baseAnchors": state.base_anchors + } + }); + write_json(out_dir.join("dashboard-state.json"), &dashboard)?; write_json(out_dir.join("indexer-handoff.json"), &indexer)?; write_json(out_dir.join("verifier-handoff.json"), &verifier)?; + write_json(out_dir.join("control-plane-handoff.json"), &control_plane)?; + write_json(out_dir.join("genesis-config.json"), &state.config)?; + write_json( + out_dir.join("operator-key-references.json"), + &state.operator_key_references, + )?; write_json(out_dir.join("state.json"), state)?; Ok(()) } +fn write_runtime_boundary_files(state_path: &Path, state: &crate::model::ChainState) -> Result<()> { + let out_dir = state_path.parent().unwrap_or_else(|| Path::new(".")); + write_json(out_dir.join("genesis-config.json"), &state.config)?; + write_json( + out_dir.join("operator-key-references.json"), + &state.operator_key_references, + )?; + Ok(()) +} + fn write_json(path: PathBuf, value: &T) -> Result<()> { let body = serde_json::to_string_pretty(value)?; fs::write(&path, format!("{body}\n")) @@ -357,10 +512,19 @@ struct StateSummary { logical_time: u64, parent_hash: String, state_root: String, + map_roots: crate::model::StateMapRoots, + operator_key_references: usize, pending_txs: usize, blocks: usize, rootfields: usize, + agent_accounts: usize, + model_passports: usize, + memory_cells: usize, + challenges: usize, + finality_receipts: usize, artifact_commitments: usize, + artifact_availability_proofs: usize, + verifier_modules: usize, work_receipts: usize, verifier_reports: usize, imported_observations: usize, @@ -377,10 +541,19 @@ impl StateSummary { logical_time: state.logical_time, parent_hash: state.parent_hash.clone(), state_root: state_root(state), + map_roots: state_map_roots(state), + operator_key_references: state.operator_key_references.len(), pending_txs: state.pending_txs.len(), blocks: state.blocks.len(), rootfields: state.rootfields.len(), + agent_accounts: state.agent_accounts.len(), + model_passports: state.model_passports.len(), + memory_cells: state.memory_cells.len(), + challenges: state.challenges.len(), + finality_receipts: state.finality_receipts.len(), artifact_commitments: state.artifact_commitments.len(), + artifact_availability_proofs: state.artifact_availability_proofs.len(), + verifier_modules: state.verifier_modules.len(), work_receipts: state.work_receipts.len(), verifier_reports: state.verifier_reports.len(), imported_observations: state.imported_observations.len(), @@ -390,12 +563,36 @@ impl StateSummary { } } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct RunSummary { + schema: String, + blocks_produced: usize, + block_hashes: Vec, + next_block_number: u64, + state_root: String, +} + +impl RunSummary { + fn from_blocks(state: &crate::model::ChainState, blocks: Vec) -> Self { + Self { + schema: "flowmemory.local_devnet.run_summary.v0".to_string(), + blocks_produced: blocks.len(), + block_hashes: blocks.into_iter().map(|block| block.block_hash).collect(), + next_block_number: state.next_block_number, + state_root: state_root(state), + } + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct ExportSummary { schema: String, out_dir: PathBuf, state_root: String, + map_roots: crate::model::StateMapRoots, + files: Vec, } impl ExportSummary { @@ -404,6 +601,48 @@ impl ExportSummary { schema: "flowmemory.local_devnet.export_summary.v0".to_string(), out_dir, state_root: state_root(state), + map_roots: state_map_roots(state), + files: handoff_files(), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ExportStateSummary { + schema: String, + out: PathBuf, + state_root: String, +} + +impl ExportStateSummary { + fn from_state(state: &crate::model::ChainState, out: PathBuf) -> Self { + Self { + schema: "flowmemory.local_devnet.export_state_summary.v0".to_string(), + out, + state_root: state_root(state), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ImportStateSummary { + schema: String, + from: PathBuf, + state_path: PathBuf, + state_root: String, + map_roots: crate::model::StateMapRoots, +} + +impl ImportStateSummary { + fn from_state(state: &crate::model::ChainState, from: PathBuf, state_path: PathBuf) -> Self { + Self { + schema: "flowmemory.local_devnet.import_state_summary.v0".to_string(), + from, + state_path, + state_root: state_root(state), + map_roots: state_map_roots(state), } } } @@ -411,11 +650,158 @@ impl ExportSummary { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct DemoSummary { + schema: String, state_path: PathBuf, first_block_hash: String, second_block_hash: String, state_root: String, + agent_id: String, + agent_registered: bool, + work_receipt_id: String, + work_receipt_submitted: bool, + verifier_report_id: String, + verifier_report_submitted: bool, + memory_cell_id: String, + memory_cell_updated: bool, + challenge_id: String, + challenge_resolved: bool, + finality_receipt_id: String, + receipt_finalized: bool, + out_dir: PathBuf, + handoff_files: Vec, +} + +impl DemoSummary { + fn from_demo(state_path: PathBuf, out_dir: PathBuf, demo: &DemoRun) -> Self { + Self { + schema: "flowmemory.local_devnet.demo_summary.v0".to_string(), + state_path, + first_block_hash: demo.first_block_hash.clone(), + second_block_hash: demo.second_block_hash.clone(), + state_root: state_root(&demo.state), + agent_id: "agent:demo:alpha".to_string(), + agent_registered: demo.state.agent_accounts.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(), + verifier_report_submitted: demo.state.verifier_reports.contains_key("report:demo:001"), + memory_cell_id: "memory:demo:agent-alpha:core".to_string(), + memory_cell_updated: demo + .state + .memory_cells + .contains_key("memory:demo:agent-alpha:core"), + challenge_id: "challenge:demo:001".to_string(), + challenge_resolved: demo + .state + .challenges + .get("challenge:demo:001") + .is_some_and(|challenge| challenge.status == "resolved"), + finality_receipt_id: "finality:demo:001".to_string(), + receipt_finalized: demo + .state + .finality_receipts + .contains_key("finality:demo:001"), + out_dir, + handoff_files: handoff_files(), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SmokeSummary { + schema: String, + state_path: PathBuf, out_dir: PathBuf, + state_root: String, + deterministic_replay: bool, + checks: SmokeChecks, + handoff_files: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SmokeChecks { + genesis_config_initialized: bool, + operator_key_reference_present: bool, + agent_registered: bool, + model_registered: bool, + work_receipt_submitted: bool, + artifact_available: bool, + verifier_module_registered: bool, + verifier_report_submitted: bool, + memory_cell_updated: bool, + challenge_opened: bool, + challenge_resolved: bool, + receipt_finalized: bool, + base_anchor_created: bool, +} + +impl SmokeSummary { + fn from_demo( + state_path: PathBuf, + out_dir: PathBuf, + demo: &DemoRun, + deterministic_replay: bool, + ) -> Self { + Self { + schema: "flowmemory.local_devnet.smoke_summary.v0".to_string(), + state_path, + out_dir, + state_root: state_root(&demo.state), + deterministic_replay, + checks: SmokeChecks { + genesis_config_initialized: demo.state.config.no_value, + operator_key_reference_present: !demo.state.operator_key_references.is_empty(), + agent_registered: demo.state.agent_accounts.contains_key("agent:demo:alpha"), + model_registered: demo + .state + .model_passports + .contains_key("model:demo:local-alpha"), + work_receipt_submitted: demo.state.work_receipts.contains_key("receipt:demo:001"), + artifact_available: demo + .state + .artifact_availability_proofs + .contains_key("availability:demo:001"), + verifier_module_registered: demo + .state + .verifier_modules + .contains_key("verifier:local-demo"), + verifier_report_submitted: demo + .state + .verifier_reports + .contains_key("report:demo:001"), + memory_cell_updated: demo + .state + .memory_cells + .contains_key("memory:demo:agent-alpha:core"), + challenge_opened: demo.state.challenges.contains_key("challenge:demo:001"), + challenge_resolved: demo + .state + .challenges + .get("challenge:demo:001") + .is_some_and(|challenge| challenge.status == "resolved"), + receipt_finalized: demo + .state + .finality_receipts + .contains_key("finality:demo:001"), + base_anchor_created: !demo.state.base_anchors.is_empty(), + }, + handoff_files: handoff_files(), + } + } +} + +fn handoff_files() -> Vec { + vec![ + "dashboard-state.json".to_string(), + "indexer-handoff.json".to_string(), + "verifier-handoff.json".to_string(), + "control-plane-handoff.json".to_string(), + "genesis-config.json".to_string(), + "operator-key-references.json".to_string(), + "state.json".to_string(), + ] } #[allow(dead_code)] diff --git a/crates/flowmemory-devnet/src/lib.rs b/crates/flowmemory-devnet/src/lib.rs index 11ad1792..55fd466f 100644 --- a/crates/flowmemory-devnet/src/lib.rs +++ b/crates/flowmemory-devnet/src/lib.rs @@ -6,7 +6,10 @@ pub mod storage; pub use cli::run_cli; pub use hash::{canonical_json, keccak_hex}; pub use model::{ - BaseAnchorPlaceholder, Block, BlockReceipt, ChainState, DevnetError, - ImportedFlowPulseObservation, ImportedVerifierReport, Transaction, TxEnvelope, - apply_transaction, build_block, genesis_state, state_root, + 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, }; diff --git a/crates/flowmemory-devnet/src/model.rs b/crates/flowmemory-devnet/src/model.rs index dba9835d..e748e211 100644 --- a/crates/flowmemory-devnet/src/model.rs +++ b/crates/flowmemory-devnet/src/model.rs @@ -6,6 +6,8 @@ use thiserror::Error; pub const STATE_SCHEMA: &str = "flowmemory.local_devnet.state.v0"; pub const BLOCK_SCHEMA: &str = "flowmemory.local_devnet.block.v0"; pub const TX_SCHEMA: &str = "flowmemory.local_devnet.tx.v0"; +pub const CONFIG_SCHEMA: &str = "flowmemory.local_devnet.config.v0"; +pub const OPERATOR_KEY_REFERENCE_SCHEMA: &str = "flowmemory.local_devnet.operator_key_reference.v0"; pub const GENESIS_HASH: &str = "0x0f23c892cbd2d00c10839d97ddab833698a83f8df8d6df27ceac03cfdd4b7bc9"; pub const ZERO_HASH: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; pub const FLOWPULSE_TOPIC0: &str = @@ -13,6 +15,26 @@ pub const FLOWPULSE_TOPIC0: &str = #[derive(Debug, Error, PartialEq, Eq)] pub enum DevnetError { + #[error("agent already exists: {0}")] + AgentAlreadyExists(String), + #[error("agent does not exist: {0}")] + AgentMissing(String), + #[error("agent is inactive: {0}")] + AgentInactive(String), + #[error("model passport already exists: {0}")] + ModelPassportAlreadyExists(String), + #[error("model passport does not exist: {0}")] + ModelPassportMissing(String), + #[error("memory cell ownership mismatch: {0}")] + MemoryCellOwnershipMismatch(String), + #[error("challenge already exists: {0}")] + ChallengeAlreadyExists(String), + #[error("challenge does not exist: {0}")] + ChallengeMissing(String), + #[error("challenge is already resolved: {0}")] + ChallengeAlreadyResolved(String), + #[error("receipt has unresolved challenge: {0}")] + ChallengeUnresolved(String), #[error("rootfield already exists: {0}")] RootfieldAlreadyExists(String), #[error("rootfield does not exist: {0}")] @@ -21,12 +43,36 @@ pub enum DevnetError { RootfieldInactive(String), #[error("artifact commitment already exists: {0}")] ArtifactAlreadyExists(String), + #[error("artifact commitment does not exist: {0}")] + ArtifactMissing(String), + #[error("artifact commitment rootfield mismatch: {0}")] + ArtifactRootfieldMismatch(String), + #[error("artifact availability proof already exists: {0}")] + ArtifactAvailabilityAlreadyExists(String), #[error("work receipt already exists: {0}")] WorkReceiptAlreadyExists(String), #[error("work receipt does not exist: {0}")] WorkReceiptMissing(String), + #[error("work receipt belongs to a different rootfield: {0}")] + WorkReceiptRootfieldMismatch(String), + #[error("work receipt is not accepted: {0}")] + WorkReceiptNotAccepted(String), + #[error("work receipt has failed verifier status: {0}")] + WorkReceiptFailed(String), + #[error("work receipt is already finalized: {0}")] + WorkReceiptAlreadyFinalized(String), + #[error("invalid finality status: {0}")] + InvalidFinalityStatus(String), #[error("verifier report already exists: {0}")] VerifierReportAlreadyExists(String), + #[error("verifier module already exists: {0}")] + VerifierModuleAlreadyExists(String), + #[error("verifier module does not exist: {0}")] + VerifierModuleMissing(String), + #[error("verifier module is inactive: {0}")] + VerifierModuleInactive(String), + #[error("finality receipt already exists: {0}")] + FinalityReceiptAlreadyExists(String), #[error("imported observation already exists: {0}")] ImportedObservationAlreadyExists(String), #[error("imported verifier report already exists: {0}")] @@ -41,13 +87,31 @@ pub enum DevnetError { #[serde(rename_all = "camelCase")] pub struct ChainState { pub schema: String, + #[serde(default = "default_config")] + pub config: DevnetConfig, pub chain_id: String, pub genesis_hash: String, pub next_block_number: u64, pub logical_time: u64, pub parent_hash: String, + #[serde(default = "default_operator_key_references")] + pub operator_key_references: BTreeMap, pub rootfields: BTreeMap, + #[serde(default)] + pub agent_accounts: BTreeMap, + #[serde(default)] + pub model_passports: BTreeMap, + #[serde(default)] + pub memory_cells: BTreeMap, + #[serde(default)] + pub challenges: BTreeMap, + #[serde(default)] + pub finality_receipts: BTreeMap, pub artifact_commitments: BTreeMap, + #[serde(default)] + pub artifact_availability_proofs: BTreeMap, + #[serde(default)] + pub verifier_modules: BTreeMap, pub work_receipts: BTreeMap, pub verifier_reports: BTreeMap, pub imported_observations: BTreeMap, @@ -57,6 +121,36 @@ pub struct ChainState { pub pending_txs: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DevnetConfig { + pub schema: String, + pub chain_id: String, + pub network_id: String, + pub genesis_hash: String, + pub genesis_logical_time: u64, + pub block_time_seconds: u64, + pub operator_key_reference_id: String, + pub no_value: bool, + pub consensus: String, + pub crypto_schema_refs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct OperatorKeyReference { + pub schema: String, + pub key_reference_id: String, + pub operator_id: String, + pub worker_key_id: String, + pub verifier_key_id: String, + pub verifier_set_root: String, + pub signature_scheme: String, + pub public_key_hint: String, + pub secret_material_boundary: String, + pub crypto_schema_refs: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Rootfield { @@ -70,6 +164,70 @@ pub struct Rootfield { pub active: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AgentAccount { + pub agent_id: String, + pub controller: String, + pub model_passport_id: Option, + pub metadata_hash: String, + pub memory_root: String, + pub active: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ModelPassport { + pub model_passport_id: String, + pub issuer: String, + pub model_family: String, + pub model_hash: String, + pub metadata_hash: String, + pub active: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct MemoryCell { + pub memory_cell_id: String, + pub agent_id: String, + pub rootfield_id: String, + pub current_root: String, + pub parent_root: String, + pub source_receipt_id: String, + pub memory_delta_root: String, + pub status: String, + pub update_count: u64, + pub updated_at_block: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Challenge { + pub challenge_id: String, + pub receipt_id: String, + pub challenger: String, + pub evidence_hash: String, + pub reason_code: String, + pub status: String, + pub resolution: Option, + pub opened_at_block: u64, + pub resolved_at_block: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct FinalityReceipt { + pub finality_receipt_id: String, + pub receipt_id: String, + pub rootfield_id: String, + pub finalized_by: String, + pub finality_status: String, + pub challenge_count: u64, + pub finalized_at_block: u64, + pub state_root: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ArtifactCommitment { @@ -79,6 +237,19 @@ pub struct ArtifactCommitment { pub uri_hint: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ArtifactAvailabilityProof { + pub proof_id: String, + pub artifact_id: String, + pub rootfield_id: String, + pub commitment: String, + pub proof_digest: String, + pub storage_backend: String, + pub status: String, + pub checked_at_block: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WorkReceipt { @@ -103,6 +274,17 @@ pub struct VerifierReport { pub reason_codes: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VerifierModule { + pub verifier_id: String, + pub operator: String, + pub module_hash: String, + pub rule_set: String, + pub metadata_hash: String, + pub active: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ImportedFlowPulseObservation { @@ -142,6 +324,22 @@ pub struct BaseAnchorPlaceholder { pub verifier_report_root: String, pub rootfield_state_root: String, pub artifact_commitment_root: String, + #[serde(default)] + pub operator_key_reference_root: String, + #[serde(default)] + pub agent_account_root: String, + #[serde(default)] + pub model_passport_root: String, + #[serde(default)] + pub memory_cell_root: String, + #[serde(default)] + pub challenge_root: String, + #[serde(default)] + pub finality_receipt_root: String, + #[serde(default)] + pub artifact_availability_proof_root: String, + #[serde(default)] + pub verifier_module_root: String, pub previous_anchor_id: String, pub finality_status: String, } @@ -159,6 +357,19 @@ pub enum Transaction { schema_hash: String, metadata_hash: String, }, + RegisterAgent { + agent_id: String, + controller: String, + model_passport_id: Option, + metadata_hash: String, + }, + RegisterModelPassport { + model_passport_id: String, + issuer: String, + model_family: String, + model_hash: String, + metadata_hash: String, + }, CommitRoot { rootfield_id: String, actor: String, @@ -171,6 +382,14 @@ pub enum Transaction { commitment: String, uri_hint: Option, }, + MarkArtifactAvailability { + proof_id: String, + artifact_id: String, + rootfield_id: String, + proof_digest: String, + storage_backend: String, + status: String, + }, SubmitWorkReceipt { receipt_id: String, rootfield_id: String, @@ -189,6 +408,39 @@ pub enum Transaction { status: String, reason_codes: Vec, }, + RegisterVerifierModule { + verifier_id: String, + operator: String, + module_hash: String, + rule_set: String, + metadata_hash: String, + }, + UpdateMemoryCell { + memory_cell_id: String, + agent_id: String, + rootfield_id: String, + source_receipt_id: String, + new_root: String, + memory_delta_root: String, + }, + OpenChallenge { + challenge_id: String, + receipt_id: String, + challenger: String, + evidence_hash: String, + reason_code: String, + }, + ResolveChallenge { + challenge_id: String, + resolver: String, + resolution: String, + }, + FinalizeWorkReceipt { + finality_receipt_id: String, + receipt_id: String, + finalized_by: String, + finality_status: String, + }, AnchorBatchToBasePlaceholder { appchain_chain_id: String, finality_status: String, @@ -229,10 +481,19 @@ pub struct BlockReceipt { #[serde(rename_all = "camelCase")] struct StateCommitmentView<'a> { schema: &'a str, + config: &'a DevnetConfig, chain_id: &'a str, genesis_hash: &'a str, + operator_key_references: &'a BTreeMap, rootfields: &'a BTreeMap, + agent_accounts: &'a BTreeMap, + model_passports: &'a BTreeMap, + memory_cells: &'a BTreeMap, + challenges: &'a BTreeMap, + finality_receipts: &'a BTreeMap, artifact_commitments: &'a BTreeMap, + artifact_availability_proofs: &'a BTreeMap, + verifier_modules: &'a BTreeMap, work_receipts: &'a BTreeMap, verifier_reports: &'a BTreeMap, imported_observations: &'a BTreeMap, @@ -247,16 +508,85 @@ struct RootMapView<'a, T> { entries: &'a BTreeMap, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StateMapRoots { + pub operator_key_reference_root: String, + pub rootfield_state_root: String, + pub agent_account_root: String, + pub model_passport_root: String, + pub memory_cell_root: String, + pub challenge_root: String, + pub finality_receipt_root: String, + pub artifact_commitment_root: String, + pub artifact_availability_proof_root: String, + pub verifier_module_root: String, + pub work_receipt_root: String, + pub verifier_report_root: String, + pub imported_observation_root: String, + pub imported_verifier_report_root: String, + pub base_anchor_root: String, +} + +pub fn default_config() -> DevnetConfig { + DevnetConfig { + schema: CONFIG_SCHEMA.to_string(), + chain_id: "flowmemory-local-devnet-v0".to_string(), + network_id: "flowmemory-private-local".to_string(), + genesis_hash: GENESIS_HASH.to_string(), + genesis_logical_time: 1_778_688_000, + block_time_seconds: 1, + operator_key_reference_id: "operator-key:local-devnet:alpha".to_string(), + no_value: true, + consensus: "single-process deterministic local block production".to_string(), + crypto_schema_refs: vec![ + "crypto/FLOWMEMORY_CRYPTO_SPEC.md#domain-separation".to_string(), + "crypto/ATTESTATIONS.md#local-signature-helpers".to_string(), + ], + } +} + +pub fn default_operator_key_references() -> BTreeMap { + let reference = OperatorKeyReference { + schema: OPERATOR_KEY_REFERENCE_SCHEMA.to_string(), + key_reference_id: "operator-key:local-devnet:alpha".to_string(), + operator_id: keccak_hex(b"operator:flowmemory-labs-devnet"), + worker_key_id: keccak_hex(b"worker-key:flowmemory-local-devnet-alpha"), + verifier_key_id: keccak_hex(b"verifier-key:flowmemory-local-devnet-alpha"), + verifier_set_root: keccak_hex(b"verifier-set:flowmemory-local-devnet-v0"), + signature_scheme: "eip712-secp256k1-fixture-digest-only".to_string(), + public_key_hint: "local fixture boundary; no public key registry is implemented" + .to_string(), + secret_material_boundary: + "no signing secret material is stored in devnet state or handoff output".to_string(), + crypto_schema_refs: vec![ + "crypto/FLOWMEMORY_CRYPTO_SPEC.md#domain-separation".to_string(), + "crypto/ATTESTATIONS.md#local-signature-helpers".to_string(), + ], + }; + BTreeMap::from([(reference.key_reference_id.clone(), reference)]) +} + pub fn genesis_state() -> ChainState { + let config = default_config(); ChainState { schema: STATE_SCHEMA.to_string(), - chain_id: "flowmemory-local-devnet-v0".to_string(), - genesis_hash: GENESIS_HASH.to_string(), + chain_id: config.chain_id.clone(), + genesis_hash: config.genesis_hash.clone(), next_block_number: 1, - logical_time: 1_778_688_000, - parent_hash: GENESIS_HASH.to_string(), + logical_time: config.genesis_logical_time, + parent_hash: config.genesis_hash.clone(), + config, + operator_key_references: default_operator_key_references(), rootfields: BTreeMap::new(), + agent_accounts: BTreeMap::new(), + model_passports: BTreeMap::new(), + memory_cells: BTreeMap::new(), + challenges: BTreeMap::new(), + finality_receipts: BTreeMap::new(), artifact_commitments: BTreeMap::new(), + artifact_availability_proofs: BTreeMap::new(), + verifier_modules: BTreeMap::new(), work_receipts: BTreeMap::new(), verifier_reports: BTreeMap::new(), imported_observations: BTreeMap::new(), @@ -282,10 +612,19 @@ pub fn queue_transaction(state: &mut ChainState, tx: Transaction) -> String { pub fn state_root(state: &ChainState) -> String { let view = StateCommitmentView { schema: STATE_SCHEMA, + config: &state.config, chain_id: &state.chain_id, genesis_hash: &state.genesis_hash, + operator_key_references: &state.operator_key_references, rootfields: &state.rootfields, + agent_accounts: &state.agent_accounts, + model_passports: &state.model_passports, + memory_cells: &state.memory_cells, + challenges: &state.challenges, + finality_receipts: &state.finality_receipts, artifact_commitments: &state.artifact_commitments, + artifact_availability_proofs: &state.artifact_availability_proofs, + verifier_modules: &state.verifier_modules, work_receipts: &state.work_receipts, verifier_reports: &state.verifier_reports, imported_observations: &state.imported_observations, @@ -302,6 +641,65 @@ pub fn map_root(schema: &'static str, entries: &BTreeMap StateMapRoots { + StateMapRoots { + operator_key_reference_root: map_root( + "flowmemory.local_devnet.operator_key_references.v0", + &state.operator_key_references, + ), + rootfield_state_root: map_root("flowmemory.local_devnet.rootfields.v0", &state.rootfields), + agent_account_root: map_root( + "flowmemory.local_devnet.agent_accounts.v0", + &state.agent_accounts, + ), + model_passport_root: map_root( + "flowmemory.local_devnet.model_passports.v0", + &state.model_passports, + ), + memory_cell_root: map_root( + "flowmemory.local_devnet.memory_cells.v0", + &state.memory_cells, + ), + challenge_root: map_root("flowmemory.local_devnet.challenges.v0", &state.challenges), + finality_receipt_root: map_root( + "flowmemory.local_devnet.finality_receipts.v0", + &state.finality_receipts, + ), + artifact_commitment_root: map_root( + "flowmemory.local_devnet.artifact_commitments.v0", + &state.artifact_commitments, + ), + artifact_availability_proof_root: map_root( + "flowmemory.local_devnet.artifact_availability_proofs.v0", + &state.artifact_availability_proofs, + ), + verifier_module_root: map_root( + "flowmemory.local_devnet.verifier_modules.v0", + &state.verifier_modules, + ), + work_receipt_root: map_root( + "flowmemory.local_devnet.work_receipts.v0", + &state.work_receipts, + ), + verifier_report_root: map_root( + "flowmemory.local_devnet.verifier_reports.v0", + &state.verifier_reports, + ), + imported_observation_root: map_root( + "flowmemory.local_devnet.imported_observations.v0", + &state.imported_observations, + ), + imported_verifier_report_root: map_root( + "flowmemory.local_devnet.imported_verifier_reports.v0", + &state.imported_verifier_reports, + ), + base_anchor_root: map_root( + "flowmemory.local_devnet.base_anchors.v0", + &state.base_anchors, + ), + } +} + pub fn build_block(state: &mut ChainState) -> Block { let txs = std::mem::take(&mut state.pending_txs); let mut receipts = Vec::with_capacity(txs.len()); @@ -372,6 +770,56 @@ pub fn apply_transaction(state: &mut ChainState, tx: &Transaction) -> Result<(), }, ); } + Transaction::RegisterAgent { + agent_id, + controller, + model_passport_id, + metadata_hash, + } => { + if state.agent_accounts.contains_key(agent_id) { + return Err(DevnetError::AgentAlreadyExists(agent_id.clone())); + } + if let Some(model_passport_id) = model_passport_id + && !state.model_passports.contains_key(model_passport_id) + { + return Err(DevnetError::ModelPassportMissing(model_passport_id.clone())); + } + state.agent_accounts.insert( + agent_id.clone(), + AgentAccount { + agent_id: agent_id.clone(), + controller: controller.clone(), + model_passport_id: model_passport_id.clone(), + metadata_hash: metadata_hash.clone(), + memory_root: ZERO_HASH.to_string(), + active: true, + }, + ); + } + Transaction::RegisterModelPassport { + model_passport_id, + issuer, + model_family, + model_hash, + metadata_hash, + } => { + if state.model_passports.contains_key(model_passport_id) { + return Err(DevnetError::ModelPassportAlreadyExists( + model_passport_id.clone(), + )); + } + state.model_passports.insert( + model_passport_id.clone(), + ModelPassport { + model_passport_id: model_passport_id.clone(), + issuer: issuer.clone(), + model_family: model_family.clone(), + model_hash: model_hash.clone(), + metadata_hash: metadata_hash.clone(), + active: true, + }, + ); + } Transaction::CommitRoot { rootfield_id, actor: _, @@ -409,6 +857,42 @@ pub fn apply_transaction(state: &mut ChainState, tx: &Transaction) -> Result<(), }, ); } + Transaction::MarkArtifactAvailability { + proof_id, + artifact_id, + rootfield_id, + proof_digest, + storage_backend, + status, + } => { + ensure_rootfield_exists(state, rootfield_id)?; + if state.artifact_availability_proofs.contains_key(proof_id) { + return Err(DevnetError::ArtifactAvailabilityAlreadyExists( + proof_id.clone(), + )); + } + let artifact = state + .artifact_commitments + .get(artifact_id) + .ok_or_else(|| DevnetError::ArtifactMissing(artifact_id.clone()))?; + if artifact.rootfield_id != rootfield_id.as_str() { + return Err(DevnetError::ArtifactRootfieldMismatch(artifact_id.clone())); + } + let commitment = artifact.commitment.clone(); + state.artifact_availability_proofs.insert( + proof_id.clone(), + ArtifactAvailabilityProof { + proof_id: proof_id.clone(), + artifact_id: artifact_id.clone(), + rootfield_id: rootfield_id.clone(), + commitment, + proof_digest: proof_digest.clone(), + storage_backend: storage_backend.clone(), + status: status.clone(), + checked_at_block: state.next_block_number, + }, + ); + } Transaction::SubmitWorkReceipt { receipt_id, rootfield_id, @@ -419,6 +903,12 @@ pub fn apply_transaction(state: &mut ChainState, tx: &Transaction) -> Result<(), rule_set, } => { ensure_rootfield_exists(state, rootfield_id)?; + if !state.artifact_commitments.values().any(|artifact| { + artifact.rootfield_id == rootfield_id.as_str() + && artifact.commitment == artifact_commitment.as_str() + }) { + return Err(DevnetError::ArtifactMissing(artifact_commitment.clone())); + } if state.work_receipts.contains_key(receipt_id) { return Err(DevnetError::WorkReceiptAlreadyExists(receipt_id.clone())); } @@ -445,8 +935,15 @@ pub fn apply_transaction(state: &mut ChainState, tx: &Transaction) -> Result<(), reason_codes, } => { ensure_rootfield_exists(state, rootfield_id)?; - if !state.work_receipts.contains_key(receipt_id) { - return Err(DevnetError::WorkReceiptMissing(receipt_id.clone())); + ensure_verifier_module_active(state, verifier_id)?; + let receipt = state + .work_receipts + .get(receipt_id) + .ok_or_else(|| DevnetError::WorkReceiptMissing(receipt_id.clone()))?; + if receipt.rootfield_id != rootfield_id.as_str() { + return Err(DevnetError::WorkReceiptRootfieldMismatch( + receipt_id.clone(), + )); } if state.verifier_reports.contains_key(report_id) { return Err(DevnetError::VerifierReportAlreadyExists(report_id.clone())); @@ -464,6 +961,177 @@ pub fn apply_transaction(state: &mut ChainState, tx: &Transaction) -> Result<(), }, ); } + Transaction::RegisterVerifierModule { + verifier_id, + operator, + module_hash, + rule_set, + metadata_hash, + } => { + if state.verifier_modules.contains_key(verifier_id) { + return Err(DevnetError::VerifierModuleAlreadyExists( + verifier_id.clone(), + )); + } + state.verifier_modules.insert( + verifier_id.clone(), + VerifierModule { + verifier_id: verifier_id.clone(), + operator: operator.clone(), + module_hash: module_hash.clone(), + rule_set: rule_set.clone(), + metadata_hash: metadata_hash.clone(), + active: true, + }, + ); + } + Transaction::UpdateMemoryCell { + memory_cell_id, + agent_id, + rootfield_id, + source_receipt_id, + new_root, + memory_delta_root, + } => { + ensure_rootfield_exists(state, rootfield_id)?; + ensure_agent_active(state, agent_id)?; + ensure_receipt_accepted(state, source_receipt_id, rootfield_id)?; + + let (parent_root, update_count) = + if let Some(memory_cell) = state.memory_cells.get(memory_cell_id) { + if memory_cell.agent_id != agent_id.as_str() + || memory_cell.rootfield_id != rootfield_id.as_str() + { + return Err(DevnetError::MemoryCellOwnershipMismatch( + memory_cell_id.clone(), + )); + } + ( + memory_cell.current_root.clone(), + memory_cell.update_count + 1, + ) + } else { + (ZERO_HASH.to_string(), 1) + }; + + state.memory_cells.insert( + memory_cell_id.clone(), + MemoryCell { + memory_cell_id: memory_cell_id.clone(), + agent_id: agent_id.clone(), + rootfield_id: rootfield_id.clone(), + current_root: new_root.clone(), + parent_root, + source_receipt_id: source_receipt_id.clone(), + memory_delta_root: memory_delta_root.clone(), + status: "active".to_string(), + update_count, + updated_at_block: state.next_block_number, + }, + ); + if let Some(agent) = state.agent_accounts.get_mut(agent_id) { + agent.memory_root = new_root.clone(); + } + } + Transaction::OpenChallenge { + challenge_id, + receipt_id, + challenger, + evidence_hash, + reason_code, + } => { + if state.challenges.contains_key(challenge_id) { + return Err(DevnetError::ChallengeAlreadyExists(challenge_id.clone())); + } + if !state.work_receipts.contains_key(receipt_id) { + return Err(DevnetError::WorkReceiptMissing(receipt_id.clone())); + } + if state + .finality_receipts + .values() + .any(|receipt| receipt.receipt_id == receipt_id.as_str()) + { + return Err(DevnetError::WorkReceiptAlreadyFinalized(receipt_id.clone())); + } + state.challenges.insert( + challenge_id.clone(), + Challenge { + challenge_id: challenge_id.clone(), + receipt_id: receipt_id.clone(), + challenger: challenger.clone(), + evidence_hash: evidence_hash.clone(), + reason_code: reason_code.clone(), + status: "open".to_string(), + resolution: None, + opened_at_block: state.next_block_number, + resolved_at_block: None, + }, + ); + } + Transaction::ResolveChallenge { + challenge_id, + resolver: _, + resolution, + } => { + let challenge = state + .challenges + .get_mut(challenge_id) + .ok_or_else(|| DevnetError::ChallengeMissing(challenge_id.clone()))?; + if challenge.status != "open" { + return Err(DevnetError::ChallengeAlreadyResolved(challenge_id.clone())); + } + challenge.status = "resolved".to_string(); + challenge.resolution = Some(resolution.clone()); + challenge.resolved_at_block = Some(state.next_block_number); + } + Transaction::FinalizeWorkReceipt { + finality_receipt_id, + receipt_id, + finalized_by, + finality_status, + } => { + if !is_valid_finality_status(finality_status) { + return Err(DevnetError::InvalidFinalityStatus(finality_status.clone())); + } + if state.finality_receipts.contains_key(finality_receipt_id) { + return Err(DevnetError::FinalityReceiptAlreadyExists( + finality_receipt_id.clone(), + )); + } + let receipt = ensure_receipt_accepted_for_any_rootfield(state, receipt_id)?; + let rootfield_id = receipt.rootfield_id.clone(); + if state.challenges.values().any(|challenge| { + challenge.receipt_id == receipt_id.as_str() && challenge.status == "open" + }) { + return Err(DevnetError::ChallengeUnresolved(receipt_id.clone())); + } + if state + .finality_receipts + .values() + .any(|receipt| receipt.receipt_id == receipt_id.as_str()) + { + return Err(DevnetError::WorkReceiptAlreadyFinalized(receipt_id.clone())); + } + let challenge_count = state + .challenges + .values() + .filter(|challenge| challenge.receipt_id == receipt_id.as_str()) + .count() as u64; + let finality_state_root = state_root(state); + state.finality_receipts.insert( + finality_receipt_id.clone(), + FinalityReceipt { + finality_receipt_id: finality_receipt_id.clone(), + receipt_id: receipt_id.clone(), + rootfield_id, + finalized_by: finalized_by.clone(), + finality_status: finality_status.clone(), + challenge_count, + finalized_at_block: state.next_block_number, + state_root: finality_state_root, + }, + ); + } Transaction::AnchorBatchToBasePlaceholder { appchain_chain_id, finality_status, @@ -525,19 +1193,7 @@ pub fn anchor_from_state( .map(|block| block.block_number) .unwrap_or(0); let state_root = state_root(state); - let work_receipt_root = map_root( - "flowmemory.local_devnet.work_receipts.v0", - &state.work_receipts, - ); - let verifier_report_root = map_root( - "flowmemory.local_devnet.verifier_reports.v0", - &state.verifier_reports, - ); - let rootfield_state_root = map_root("flowmemory.local_devnet.rootfields.v0", &state.rootfields); - let artifact_commitment_root = map_root( - "flowmemory.local_devnet.artifact_commitments.v0", - &state.artifact_commitments, - ); + let roots = state_map_roots(state); let previous_anchor_id = state .base_anchors @@ -558,6 +1214,14 @@ pub fn anchor_from_state( verifier_report_root: &'a str, rootfield_state_root: &'a str, artifact_commitment_root: &'a str, + operator_key_reference_root: &'a str, + agent_account_root: &'a str, + model_passport_root: &'a str, + memory_cell_root: &'a str, + challenge_root: &'a str, + finality_receipt_root: &'a str, + artifact_availability_proof_root: &'a str, + verifier_module_root: &'a str, previous_anchor_id: &'a str, finality_status: &'a str, } @@ -570,10 +1234,18 @@ pub fn anchor_from_state( block_range_start, block_range_end, state_root: &state_root, - work_receipt_root: &work_receipt_root, - verifier_report_root: &verifier_report_root, - rootfield_state_root: &rootfield_state_root, - artifact_commitment_root: &artifact_commitment_root, + work_receipt_root: &roots.work_receipt_root, + verifier_report_root: &roots.verifier_report_root, + rootfield_state_root: &roots.rootfield_state_root, + artifact_commitment_root: &roots.artifact_commitment_root, + operator_key_reference_root: &roots.operator_key_reference_root, + agent_account_root: &roots.agent_account_root, + model_passport_root: &roots.model_passport_root, + memory_cell_root: &roots.memory_cell_root, + challenge_root: &roots.challenge_root, + finality_receipt_root: &roots.finality_receipt_root, + artifact_availability_proof_root: &roots.artifact_availability_proof_root, + verifier_module_root: &roots.verifier_module_root, previous_anchor_id: &previous_anchor_id, finality_status, }, @@ -585,10 +1257,18 @@ pub fn anchor_from_state( block_range_start, block_range_end, state_root, - work_receipt_root, - verifier_report_root, - rootfield_state_root, - artifact_commitment_root, + work_receipt_root: roots.work_receipt_root, + verifier_report_root: roots.verifier_report_root, + rootfield_state_root: roots.rootfield_state_root, + artifact_commitment_root: roots.artifact_commitment_root, + operator_key_reference_root: roots.operator_key_reference_root, + agent_account_root: roots.agent_account_root, + model_passport_root: roots.model_passport_root, + memory_cell_root: roots.memory_cell_root, + challenge_root: roots.challenge_root, + finality_receipt_root: roots.finality_receipt_root, + artifact_availability_proof_root: roots.artifact_availability_proof_root, + verifier_module_root: roots.verifier_module_root, previous_anchor_id, finality_status: finality_status.to_string(), } @@ -596,8 +1276,14 @@ pub fn anchor_from_state( 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 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"); let committed_root = keccak_hex(b"flowmemory.demo.root.v0"); + let memory_root = keccak_hex(b"flowmemory.demo.memory.root.v0"); + let memory_delta_root = keccak_hex(b"flowmemory.demo.memory.delta.v0"); let receipt_id = "receipt:demo:001".to_string(); vec![ @@ -607,12 +1293,40 @@ pub fn demo_transactions() -> Vec { schema_hash: keccak_hex(b"flowmemory.rootfield.schema.v0"), metadata_hash: keccak_hex(b"flowmemory.rootfield.metadata.demo"), }, + Transaction::RegisterModelPassport { + model_passport_id: model_passport_id.clone(), + issuer: "operator:local-demo".to_string(), + model_family: "local-alpha-fixture-model".to_string(), + model_hash: keccak_hex(b"flowmemory.demo.model.local-alpha"), + metadata_hash: keccak_hex(b"flowmemory.demo.model.metadata"), + }, + Transaction::RegisterAgent { + agent_id: agent_id.clone(), + controller: "operator:local-demo".to_string(), + model_passport_id: Some(model_passport_id), + metadata_hash: keccak_hex(b"flowmemory.demo.agent.metadata"), + }, + Transaction::RegisterVerifierModule { + verifier_id: verifier_id.clone(), + operator: "operator:local-demo".to_string(), + module_hash: keccak_hex(b"flowmemory.demo.verifier.module"), + rule_set: "flowmemory.work.rule_set.local_demo.v0".to_string(), + metadata_hash: keccak_hex(b"flowmemory.demo.verifier.metadata"), + }, Transaction::SubmitArtifactCommitment { - artifact_id: "artifact:demo:001".to_string(), + artifact_id: artifact_id.clone(), rootfield_id: rootfield_id.clone(), commitment: artifact_commitment.clone(), uri_hint: Some("fixture://artifact/demo/001".to_string()), }, + Transaction::MarkArtifactAvailability { + proof_id: "availability:demo:001".to_string(), + artifact_id: artifact_id.clone(), + rootfield_id: rootfield_id.clone(), + proof_digest: keccak_hex(b"flowmemory.demo.artifact.availability"), + storage_backend: "fixture-local".to_string(), + status: "available".to_string(), + }, Transaction::CommitRoot { rootfield_id: rootfield_id.clone(), actor: "operator:local-demo".to_string(), @@ -630,13 +1344,39 @@ pub fn demo_transactions() -> Vec { }, Transaction::SubmitVerifierReport { report_id: "report:demo:001".to_string(), - rootfield_id, - receipt_id, - verifier_id: "verifier:local-demo".to_string(), + rootfield_id: rootfield_id.clone(), + receipt_id: receipt_id.clone(), + verifier_id, report_digest: keccak_hex(b"flowmemory.demo.report.digest.v0"), status: "verified".to_string(), reason_codes: Vec::new(), }, + Transaction::UpdateMemoryCell { + memory_cell_id: "memory:demo:agent-alpha:core".to_string(), + agent_id, + rootfield_id, + source_receipt_id: receipt_id.clone(), + new_root: memory_root, + memory_delta_root, + }, + Transaction::OpenChallenge { + challenge_id: "challenge:demo:001".to_string(), + receipt_id: receipt_id.clone(), + challenger: "reviewer:local-demo".to_string(), + evidence_hash: keccak_hex(b"flowmemory.demo.challenge.evidence"), + reason_code: "local-review".to_string(), + }, + Transaction::ResolveChallenge { + challenge_id: "challenge:demo:001".to_string(), + resolver: "verifier:local-demo".to_string(), + resolution: "dismissed".to_string(), + }, + Transaction::FinalizeWorkReceipt { + finality_receipt_id: "finality:demo:001".to_string(), + receipt_id, + finalized_by: "operator:local-demo".to_string(), + finality_status: "finalized".to_string(), + }, ] } @@ -647,3 +1387,88 @@ fn ensure_rootfield_exists(state: &ChainState, rootfield_id: &str) -> Result<(), None => Err(DevnetError::RootfieldMissing(rootfield_id.to_string())), } } + +fn ensure_agent_active(state: &ChainState, agent_id: &str) -> Result<(), DevnetError> { + match state.agent_accounts.get(agent_id) { + Some(agent) if agent.active => Ok(()), + Some(_) => Err(DevnetError::AgentInactive(agent_id.to_string())), + None => Err(DevnetError::AgentMissing(agent_id.to_string())), + } +} + +fn ensure_verifier_module_active(state: &ChainState, verifier_id: &str) -> Result<(), DevnetError> { + match state.verifier_modules.get(verifier_id) { + Some(verifier) if verifier.active => Ok(()), + Some(_) => Err(DevnetError::VerifierModuleInactive(verifier_id.to_string())), + None => Err(DevnetError::VerifierModuleMissing(verifier_id.to_string())), + } +} + +fn ensure_receipt_accepted<'a>( + state: &'a ChainState, + receipt_id: &str, + rootfield_id: &str, +) -> Result<&'a WorkReceipt, DevnetError> { + let receipt = state + .work_receipts + .get(receipt_id) + .ok_or_else(|| DevnetError::WorkReceiptMissing(receipt_id.to_string()))?; + if receipt.rootfield_id != rootfield_id { + return Err(DevnetError::WorkReceiptRootfieldMismatch( + receipt_id.to_string(), + )); + } + ensure_receipt_status_accepted(state, receipt_id)?; + Ok(receipt) +} + +fn ensure_receipt_accepted_for_any_rootfield<'a>( + state: &'a ChainState, + receipt_id: &str, +) -> Result<&'a WorkReceipt, DevnetError> { + let receipt = state + .work_receipts + .get(receipt_id) + .ok_or_else(|| DevnetError::WorkReceiptMissing(receipt_id.to_string()))?; + ensure_receipt_status_accepted(state, receipt_id)?; + Ok(receipt) +} + +fn ensure_receipt_status_accepted(state: &ChainState, receipt_id: &str) -> Result<(), DevnetError> { + if state + .verifier_reports + .values() + .any(|report| report.receipt_id == receipt_id && is_failed_status(&report.status)) + { + return Err(DevnetError::WorkReceiptFailed(receipt_id.to_string())); + } + if state + .verifier_reports + .values() + .any(|report| report.receipt_id == receipt_id && is_accepted_status(&report.status)) + { + return Ok(()); + } + Err(DevnetError::WorkReceiptNotAccepted(receipt_id.to_string())) +} + +fn is_accepted_status(status: &str) -> bool { + matches!( + status.to_ascii_lowercase().as_str(), + "accepted" | "verified" + ) +} + +fn is_failed_status(status: &str) -> bool { + matches!( + status.to_ascii_lowercase().as_str(), + "failed" | "invalid" | "rejected" | "unsupported" | "reorged" + ) +} + +fn is_valid_finality_status(status: &str) -> bool { + matches!( + status.to_ascii_lowercase().as_str(), + "finalized" | "local-finalized" + ) +} diff --git a/crates/flowmemory-devnet/tests/devnet_tests.rs b/crates/flowmemory-devnet/tests/devnet_tests.rs index 17723389..09691c95 100644 --- a/crates/flowmemory-devnet/tests/devnet_tests.rs +++ b/crates/flowmemory-devnet/tests/devnet_tests.rs @@ -1,6 +1,6 @@ use flowmemory_devnet::model::{ - FLOWPULSE_TOPIC0, Transaction, ZERO_HASH, build_block, demo_transactions, genesis_state, - queue_transaction, state_root, + DevnetError, FLOWPULSE_TOPIC0, Transaction, ZERO_HASH, apply_transaction, build_block, + demo_transactions, genesis_state, queue_transaction, state_map_roots, state_root, }; use flowmemory_devnet::{canonical_json, keccak_hex}; use std::process::Command; @@ -22,6 +22,14 @@ fn state_root_is_deterministic_for_same_inputs() { assert_eq!(first_block.block_hash, second_block.block_hash); } +#[test] +fn deterministic_replay_covers_new_maps_and_anchor() { + let first = run_demo_chain(); + let second = run_demo_chain(); + + assert_eq!(first, second); +} + #[test] fn block_hash_changes_when_transactions_change() { let mut first = genesis_state(); @@ -83,6 +91,104 @@ fn invalid_tx_is_rejected_without_state_mutation() { assert!(state.rootfields.is_empty()); } +#[test] +fn invalid_dependencies_are_rejected() { + let mut state = genesis_state(); + assert_eq!( + apply_transaction( + &mut state, + ®ister_agent_tx("agent:missing-model", Some("model:missing")), + ), + Err(DevnetError::ModelPassportMissing( + "model:missing".to_string() + )) + ); + + apply_transaction(&mut state, ®ister_rootfield_tx("rootfield:deps")).unwrap(); + let missing_artifact_receipt = work_receipt_tx( + "receipt:missing-artifact", + "rootfield:deps", + "artifact:missing", + ); + let Transaction::SubmitWorkReceipt { + artifact_commitment: missing_artifact_commitment, + .. + } = &missing_artifact_receipt + else { + unreachable!("helper must build a work receipt") + }; + assert_eq!( + apply_transaction(&mut state, &missing_artifact_receipt), + Err(DevnetError::ArtifactMissing( + missing_artifact_commitment.clone() + )) + ); + + assert_eq!( + apply_transaction( + &mut state, + &availability_tx( + "availability:missing-artifact", + "artifact:missing", + "rootfield:deps", + "available", + ), + ), + Err(DevnetError::ArtifactMissing("artifact:missing".to_string())) + ); + + let mut missing_verifier = genesis_state(); + apply_transaction( + &mut missing_verifier, + ®ister_rootfield_tx("rootfield:missing-verifier"), + ) + .unwrap(); + apply_transaction( + &mut missing_verifier, + &artifact_tx("artifact:missing-verifier", "rootfield:missing-verifier"), + ) + .unwrap(); + apply_transaction( + &mut missing_verifier, + &work_receipt_tx( + "receipt:missing-verifier", + "rootfield:missing-verifier", + "artifact:missing-verifier", + ), + ) + .unwrap(); + assert_eq!( + apply_transaction( + &mut missing_verifier, + &verifier_report_tx( + "report:missing-verifier", + "receipt:missing-verifier", + "rootfield:missing-verifier", + "verified", + ), + ), + Err(DevnetError::VerifierModuleMissing( + "verifier:test".to_string() + )) + ); + + apply_transaction(&mut state, ®ister_verifier_module_tx("verifier:test")).unwrap(); + assert_eq!( + apply_transaction( + &mut state, + &verifier_report_tx( + "report:missing-receipt", + "receipt:missing", + "rootfield:deps", + "verified", + ), + ), + Err(DevnetError::WorkReceiptMissing( + "receipt:missing".to_string() + )) + ); +} + #[test] fn every_core_transaction_type_can_be_applied() { let mut state = genesis_state(); @@ -142,7 +248,14 @@ fn every_core_transaction_type_can_be_applied() { .all(|receipt| receipt.status == "applied") ); assert_eq!(state.rootfields.len(), 1); + assert_eq!(state.agent_accounts.len(), 1); + assert_eq!(state.model_passports.len(), 1); + assert_eq!(state.memory_cells.len(), 1); + assert_eq!(state.challenges.len(), 1); + assert_eq!(state.finality_receipts.len(), 1); assert_eq!(state.artifact_commitments.len(), 1); + assert_eq!(state.artifact_availability_proofs.len(), 1); + assert_eq!(state.verifier_modules.len(), 1); assert_eq!(state.work_receipts.len(), 1); assert_eq!(state.verifier_reports.len(), 1); assert_eq!(state.imported_observations.len(), 1); @@ -150,6 +263,182 @@ fn every_core_transaction_type_can_be_applied() { assert_eq!(state.base_anchors.len(), 1); } +#[test] +fn duplicate_ids_are_rejected_for_new_objects() { + let mut state = genesis_state(); + apply_transaction(&mut state, ®ister_rootfield_tx("rootfield:dup")).unwrap(); + + let model = register_model_passport_tx("model:dup"); + apply_transaction(&mut state, &model).unwrap(); + assert_eq!( + apply_transaction(&mut state, &model), + Err(DevnetError::ModelPassportAlreadyExists( + "model:dup".to_string() + )) + ); + + let agent = register_agent_tx("agent:dup", Some("model:dup")); + apply_transaction(&mut state, &agent).unwrap(); + assert_eq!( + apply_transaction(&mut state, &agent), + Err(DevnetError::AgentAlreadyExists("agent:dup".to_string())) + ); + + let verifier = register_verifier_module_tx("verifier:dup"); + apply_transaction(&mut state, &verifier).unwrap(); + assert_eq!( + apply_transaction(&mut state, &verifier), + Err(DevnetError::VerifierModuleAlreadyExists( + "verifier:dup".to_string() + )) + ); + + apply_transaction(&mut state, &artifact_tx("artifact:dup", "rootfield:dup")).unwrap(); + let availability = availability_tx( + "availability:dup", + "artifact:dup", + "rootfield:dup", + "available", + ); + apply_transaction(&mut state, &availability).unwrap(); + assert_eq!( + apply_transaction(&mut state, &availability), + Err(DevnetError::ArtifactAvailabilityAlreadyExists( + "availability:dup".to_string() + )) + ); + + apply_transaction(&mut state, ®ister_verifier_module_tx("verifier:test")).unwrap(); + apply_transaction( + &mut state, + &work_receipt_tx("receipt:dup", "rootfield:dup", "artifact:dup"), + ) + .unwrap(); + apply_transaction( + &mut state, + &verifier_report_tx("report:dup", "receipt:dup", "rootfield:dup", "verified"), + ) + .unwrap(); + + let challenge = open_challenge_tx("challenge:dup", "receipt:dup"); + apply_transaction(&mut state, &challenge).unwrap(); + assert_eq!( + apply_transaction(&mut state, &challenge), + Err(DevnetError::ChallengeAlreadyExists( + "challenge:dup".to_string() + )) + ); + apply_transaction(&mut state, &resolve_challenge_tx("challenge:dup")).unwrap(); + + let finality = finalize_tx("finality:dup", "receipt:dup"); + apply_transaction(&mut state, &finality).unwrap(); + assert_eq!( + apply_transaction(&mut state, &finality), + Err(DevnetError::FinalityReceiptAlreadyExists( + "finality:dup".to_string() + )) + ); +} + +#[test] +fn memory_update_rejects_missing_or_failed_source_receipt() { + let mut missing = genesis_state(); + setup_registered_agent_and_rootfield(&mut missing, "rootfield:memory", "agent:memory"); + assert_eq!( + apply_transaction( + &mut missing, + &memory_update_tx( + "memory:missing", + "agent:memory", + "rootfield:memory", + "receipt:missing" + ), + ), + Err(DevnetError::WorkReceiptMissing( + "receipt:missing".to_string() + )) + ); + + let mut failed = genesis_state(); + setup_receipt_with_report_status(&mut failed, "rootfield:failed", "agent:failed", "failed"); + assert_eq!( + apply_transaction( + &mut failed, + &memory_update_tx( + "memory:failed", + "agent:failed", + "rootfield:failed", + "receipt:status" + ), + ), + Err(DevnetError::WorkReceiptFailed("receipt:status".to_string())) + ); +} + +#[test] +fn challenge_rejects_missing_receipt() { + let mut state = genesis_state(); + assert_eq!( + apply_transaction( + &mut state, + &open_challenge_tx("challenge:missing", "receipt:missing"), + ), + Err(DevnetError::WorkReceiptMissing( + "receipt:missing".to_string() + )) + ); +} + +#[test] +fn finalization_rejects_unresolved_challenge() { + let mut state = genesis_state(); + setup_receipt_with_report_status( + &mut state, + "rootfield:challenge", + "agent:challenge", + "verified", + ); + apply_transaction( + &mut state, + &open_challenge_tx("challenge:open", "receipt:status"), + ) + .unwrap(); + + assert_eq!( + apply_transaction( + &mut state, + &finalize_tx("finality:blocked", "receipt:status") + ), + Err(DevnetError::ChallengeUnresolved( + "receipt:status".to_string() + )) + ); +} + +#[test] +fn finalization_rejects_invalid_finality_status() { + let mut state = genesis_state(); + setup_receipt_with_report_status( + &mut state, + "rootfield:finality", + "agent:finality", + "verified", + ); + + assert_eq!( + apply_transaction( + &mut state, + &Transaction::FinalizeWorkReceipt { + finality_receipt_id: "finality:invalid".to_string(), + receipt_id: "receipt:status".to_string(), + finalized_by: "operator:test".to_string(), + finality_status: "pending".to_string(), + }, + ), + Err(DevnetError::InvalidFinalityStatus("pending".to_string())) + ); +} + #[test] fn canonical_json_sorts_object_keys() { let left = serde_json::json!({ "b": 2, "a": { "d": 4, "c": 3 } }); @@ -159,11 +448,7 @@ fn canonical_json_sorts_object_keys() { #[test] fn cli_demo_writes_state_and_handoff_files() { - let temp = std::env::temp_dir().join(format!("flowmemory-devnet-test-{}", std::process::id())); - if temp.exists() { - std::fs::remove_dir_all(&temp).expect("remove old temp dir"); - } - std::fs::create_dir_all(&temp).expect("create temp dir"); + let temp = temp_dir("cli-demo"); let state = temp.join("state.json"); let out_dir = temp.join("handoff"); @@ -183,12 +468,171 @@ fn cli_demo_writes_state_and_handoff_files() { assert!(out_dir.join("dashboard-state.json").exists()); assert!(out_dir.join("indexer-handoff.json").exists()); assert!(out_dir.join("verifier-handoff.json").exists()); + assert!(out_dir.join("control-plane-handoff.json").exists()); + assert!(out_dir.join("genesis-config.json").exists()); + assert!(out_dir.join("operator-key-references.json").exists()); let body = std::fs::read_to_string(&state).expect("state body"); assert!(body.contains("rootfield:demo:alpha")); + assert!(body.contains("agent:demo:alpha")); + assert!(body.contains("memory:demo:agent-alpha:core")); + assert!(body.contains("finality:demo:001")); assert!(!body.contains("privateKey")); + assert!(!body.contains("seed phrase")); assert!(!body.contains("tokenomics")); + let dashboard_body = + std::fs::read_to_string(out_dir.join("dashboard-state.json")).expect("dashboard body"); + assert!(dashboard_body.contains("agentAccounts")); + assert!(dashboard_body.contains("memoryCells")); + assert!(dashboard_body.contains("finalityReceipts")); + assert!(dashboard_body.contains("operatorKeyReferences")); + + std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); +} + +#[test] +fn cli_smoke_runs_full_flow() { + let temp = temp_dir("cli-smoke"); + let state = temp.join("state.json"); + let out_dir = temp.join("handoff"); + + let output = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "smoke", + "--out-dir", + out_dir.to_str().expect("out path"), + ]) + .output() + .expect("run smoke"); + assert!(output.status.success()); + + let summary: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("smoke summary json"); + assert_eq!(summary["deterministicReplay"], true); + assert_eq!(summary["checks"]["genesisConfigInitialized"], true); + assert_eq!(summary["checks"]["operatorKeyReferencePresent"], true); + assert_eq!(summary["checks"]["receiptFinalized"], true); + assert!(out_dir.join("control-plane-handoff.json").exists()); + + std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); +} + +#[test] +fn cli_generated_handoff_files_are_deterministic() { + let temp = temp_dir("deterministic-handoff"); + let state_a = temp.join("a-state.json"); + let state_b = temp.join("b-state.json"); + let out_a = temp.join("a-handoff"); + let out_b = temp.join("b-handoff"); + + for (state, out_dir) in [(&state_a, &out_a), (&state_b, &out_b)] { + let output = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "demo", + "--out-dir", + out_dir.to_str().expect("out path"), + ]) + .output() + .expect("run demo"); + assert!(output.status.success()); + } + + for file in [ + "dashboard-state.json", + "indexer-handoff.json", + "verifier-handoff.json", + "control-plane-handoff.json", + "genesis-config.json", + "operator-key-references.json", + "state.json", + ] { + let left = std::fs::read_to_string(out_a.join(file)).expect("left handoff"); + let right = std::fs::read_to_string(out_b.join(file)).expect("right handoff"); + assert_eq!(left, right, "handoff file differed: {file}"); + } + + std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); +} + +#[test] +fn cli_rejects_malformed_fixture() { + let temp = temp_dir("malformed-fixture"); + let state = temp.join("state.json"); + let fixture = temp.join("bad.json"); + std::fs::write(&fixture, "{ not valid json").expect("write bad fixture"); + + let output = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "submit-fixture", + "--fixture", + fixture.to_str().expect("fixture path"), + ]) + .output() + .expect("run submit fixture"); + + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("failed to parse fixture")); + assert!(!state.exists()); + + std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); +} + +#[test] +fn cli_export_import_state_round_trip_is_deterministic() { + let temp = temp_dir("export-import"); + let state = temp.join("state.json"); + let imported = temp.join("imported-state.json"); + let snapshot = temp.join("snapshot.json"); + + let demo_status = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "demo", + "--out-dir", + temp.join("handoff").to_str().expect("handoff path"), + ]) + .status() + .expect("run demo"); + assert!(demo_status.success()); + + let export_status = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "export-state", + "--out", + snapshot.to_str().expect("snapshot path"), + ]) + .status() + .expect("export state"); + assert!(export_status.success()); + + let import_status = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + imported.to_str().expect("imported path"), + "import-state", + "--from", + snapshot.to_str().expect("snapshot path"), + ]) + .status() + .expect("import state"); + assert!(import_status.success()); + + let original_body = std::fs::read_to_string(&state).expect("original state"); + let imported_body = std::fs::read_to_string(&imported).expect("imported state"); + assert_eq!(original_body, imported_body); + assert!(temp.join("genesis-config.json").exists()); + assert!(temp.join("operator-key-references.json").exists()); + std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); } @@ -197,3 +641,209 @@ fn zero_hash_constant_is_hex_32_bytes() { assert_eq!(ZERO_HASH.len(), 66); assert!(ZERO_HASH.starts_with("0x")); } + +fn temp_dir(name: &str) -> std::path::PathBuf { + let temp = std::env::temp_dir().join(format!( + "flowmemory-devnet-test-{}-{name}", + std::process::id() + )); + if temp.exists() { + std::fs::remove_dir_all(&temp).expect("remove old temp dir"); + } + std::fs::create_dir_all(&temp).expect("create temp dir"); + temp +} + +fn run_demo_chain() -> ( + String, + String, + String, + flowmemory_devnet::model::StateMapRoots, +) { + let mut state = genesis_state(); + for tx in demo_transactions() { + queue_transaction(&mut state, tx); + } + let first = build_block(&mut state); + let appchain_chain_id = state.chain_id.clone(); + queue_transaction( + &mut state, + Transaction::AnchorBatchToBasePlaceholder { + appchain_chain_id, + finality_status: "local-placeholder".to_string(), + }, + ); + let second = build_block(&mut state); + ( + first.block_hash, + second.block_hash, + state_root(&state), + state_map_roots(&state), + ) +} + +fn setup_registered_agent_and_rootfield( + state: &mut flowmemory_devnet::model::ChainState, + rootfield_id: &str, + agent_id: &str, +) { + apply_transaction(state, ®ister_rootfield_tx(rootfield_id)).unwrap(); + apply_transaction(state, ®ister_model_passport_tx("model:status")).unwrap(); + apply_transaction(state, ®ister_agent_tx(agent_id, Some("model:status"))).unwrap(); +} + +fn setup_receipt_with_report_status( + state: &mut flowmemory_devnet::model::ChainState, + rootfield_id: &str, + agent_id: &str, + status: &str, +) { + setup_registered_agent_and_rootfield(state, rootfield_id, agent_id); + apply_transaction(state, &artifact_tx("artifact:status", rootfield_id)).unwrap(); + apply_transaction(state, ®ister_verifier_module_tx("verifier:test")).unwrap(); + apply_transaction( + state, + &work_receipt_tx("receipt:status", rootfield_id, "artifact:status"), + ) + .unwrap(); + apply_transaction( + state, + &verifier_report_tx("report:status", "receipt:status", rootfield_id, status), + ) + .unwrap(); +} + +fn register_rootfield_tx(rootfield_id: &str) -> Transaction { + Transaction::RegisterRootfield { + rootfield_id: rootfield_id.to_string(), + owner: "operator:test".to_string(), + schema_hash: keccak_hex(b"schema:test"), + metadata_hash: keccak_hex(b"metadata:test"), + } +} + +fn register_model_passport_tx(model_passport_id: &str) -> Transaction { + Transaction::RegisterModelPassport { + model_passport_id: model_passport_id.to_string(), + issuer: "operator:test".to_string(), + model_family: "fixture-model".to_string(), + model_hash: keccak_hex(format!("model:{model_passport_id}").as_bytes()), + metadata_hash: keccak_hex(format!("model-metadata:{model_passport_id}").as_bytes()), + } +} + +fn register_agent_tx(agent_id: &str, model_passport_id: Option<&str>) -> Transaction { + Transaction::RegisterAgent { + agent_id: agent_id.to_string(), + controller: "operator:test".to_string(), + model_passport_id: model_passport_id.map(ToOwned::to_owned), + metadata_hash: keccak_hex(format!("agent-metadata:{agent_id}").as_bytes()), + } +} + +fn register_verifier_module_tx(verifier_id: &str) -> Transaction { + Transaction::RegisterVerifierModule { + verifier_id: verifier_id.to_string(), + operator: "operator:test".to_string(), + module_hash: keccak_hex(format!("verifier-module:{verifier_id}").as_bytes()), + rule_set: "flowmemory.work.rule_set.test.v0".to_string(), + metadata_hash: keccak_hex(format!("verifier-metadata:{verifier_id}").as_bytes()), + } +} + +fn artifact_tx(artifact_id: &str, rootfield_id: &str) -> Transaction { + Transaction::SubmitArtifactCommitment { + artifact_id: artifact_id.to_string(), + rootfield_id: rootfield_id.to_string(), + commitment: keccak_hex(format!("artifact:{artifact_id}").as_bytes()), + uri_hint: Some(format!("fixture://artifact/{artifact_id}")), + } +} + +fn availability_tx( + proof_id: &str, + artifact_id: &str, + rootfield_id: &str, + status: &str, +) -> Transaction { + Transaction::MarkArtifactAvailability { + proof_id: proof_id.to_string(), + artifact_id: artifact_id.to_string(), + rootfield_id: rootfield_id.to_string(), + proof_digest: keccak_hex(format!("availability:{proof_id}").as_bytes()), + storage_backend: "fixture-local".to_string(), + status: status.to_string(), + } +} + +fn work_receipt_tx(receipt_id: &str, rootfield_id: &str, artifact_id: &str) -> Transaction { + Transaction::SubmitWorkReceipt { + receipt_id: receipt_id.to_string(), + rootfield_id: rootfield_id.to_string(), + worker_id: "worker:test".to_string(), + input_root: ZERO_HASH.to_string(), + output_root: keccak_hex(format!("output:{receipt_id}").as_bytes()), + artifact_commitment: keccak_hex(format!("artifact:{artifact_id}").as_bytes()), + rule_set: "flowmemory.work.rule_set.test.v0".to_string(), + } +} + +fn verifier_report_tx( + report_id: &str, + receipt_id: &str, + rootfield_id: &str, + status: &str, +) -> Transaction { + Transaction::SubmitVerifierReport { + report_id: report_id.to_string(), + rootfield_id: rootfield_id.to_string(), + receipt_id: receipt_id.to_string(), + verifier_id: "verifier:test".to_string(), + report_digest: keccak_hex(format!("report:{report_id}:{status}").as_bytes()), + status: status.to_string(), + reason_codes: Vec::new(), + } +} + +fn memory_update_tx( + memory_cell_id: &str, + agent_id: &str, + rootfield_id: &str, + source_receipt_id: &str, +) -> Transaction { + Transaction::UpdateMemoryCell { + memory_cell_id: memory_cell_id.to_string(), + agent_id: agent_id.to_string(), + rootfield_id: rootfield_id.to_string(), + source_receipt_id: source_receipt_id.to_string(), + new_root: keccak_hex(format!("memory-root:{memory_cell_id}").as_bytes()), + memory_delta_root: keccak_hex(format!("memory-delta:{memory_cell_id}").as_bytes()), + } +} + +fn open_challenge_tx(challenge_id: &str, receipt_id: &str) -> Transaction { + Transaction::OpenChallenge { + challenge_id: challenge_id.to_string(), + receipt_id: receipt_id.to_string(), + challenger: "reviewer:test".to_string(), + evidence_hash: keccak_hex(format!("challenge-evidence:{challenge_id}").as_bytes()), + reason_code: "unit-test".to_string(), + } +} + +fn resolve_challenge_tx(challenge_id: &str) -> Transaction { + Transaction::ResolveChallenge { + challenge_id: challenge_id.to_string(), + resolver: "verifier:test".to_string(), + resolution: "dismissed".to_string(), + } +} + +fn finalize_tx(finality_receipt_id: &str, receipt_id: &str) -> Transaction { + Transaction::FinalizeWorkReceipt { + finality_receipt_id: finality_receipt_id.to_string(), + receipt_id: receipt_id.to_string(), + finalized_by: "operator:test".to_string(), + finality_status: "finalized".to_string(), + } +} diff --git a/devnet/README.md b/devnet/README.md index 9fd83e7c..3894a681 100644 --- a/devnet/README.md +++ b/devnet/README.md @@ -13,7 +13,8 @@ devnet/local/state.json Use: ```powershell -cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- demo +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- init +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- smoke ``` See [docs/LOCAL_DEVNET.md](../docs/LOCAL_DEVNET.md) for full commands. diff --git a/docs/LOCAL_DEVNET.md b/docs/LOCAL_DEVNET.md index aee8c039..fa19bc58 100644 --- a/docs/LOCAL_DEVNET.md +++ b/docs/LOCAL_DEVNET.md @@ -1,8 +1,10 @@ # FlowMemory Local Devnet -Status: runnable no-value prototype +Status: runnable no-value local runtime -The local FlowMemory devnet is a Rust CLI that models FlowMemory appchain-style state transitions without production consensus, tokenomics, bridge assets, or mainnet claims. +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. ## Framework Decision @@ -33,6 +35,16 @@ Initialize state: cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- init ``` +`init` writes deterministic genesis state plus local boundary files next to the state file: + +```text +devnet/local/state.json +devnet/local/genesis-config.json +devnet/local/operator-key-references.json +``` + +The operator key file is a reference boundary only. It records local fixture identifiers and crypto schema references, but no signing secret material. + Reset local state: ```powershell @@ -63,6 +75,20 @@ Build a block from pending transactions: cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- run-block ``` +Run a bounded local block-production loop: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- start --blocks 3 +``` + +`run --blocks 3` is an alias for `start --blocks 3`. + +Run the full smoke flow: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- smoke +``` + Import a FlowPulse observation fixture: ```powershell @@ -83,6 +109,15 @@ Export handoff fixtures: cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- export-fixtures --out-dir fixtures/handoff/generated ``` +`export` is an alias for `export-fixtures`. + +Export and import a full state snapshot: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- export-state --out fixtures/handoff/generated/state-snapshot.json +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state devnet/local/imported-state.json import-state --from fixtures/handoff/generated/state-snapshot.json +``` + Use a custom state path: ```powershell @@ -94,23 +129,40 @@ cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state devnet/ The demo: 1. Starts from deterministic genesis. -2. Registers a rootfield. -3. Submits an artifact commitment. -4. Commits a latest root. -5. Submits a work receipt. -6. Submits a verifier report. -7. Builds block 1. -8. Creates a Base settlement anchor placeholder. -9. Builds block 2. -10. Writes state to `devnet/local/state.json`. -11. Exports handoff files to `fixtures/handoff/generated/`. +2. Initializes local no-value genesis config and operator key references. +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/`. ## State Model The prototype stores: +- `config` +- `operatorKeyReferences` - `rootfields` +- `agentAccounts` +- `modelPassports` +- `memoryCells` +- `challenges` +- `finalityReceipts` - `artifactCommitments` +- `artifactAvailabilityProofs` +- `verifierModules` - `workReceipts` - `verifierReports` - `importedObservations` @@ -126,14 +178,34 @@ There are no token balances and no gas accounting. Supported local transactions: - `RegisterRootfield` +- `RegisterAgent` +- `RegisterModelPassport` - `CommitRoot` - `SubmitArtifactCommitment` +- `MarkArtifactAvailability` - `SubmitWorkReceipt` - `SubmitVerifierReport` +- `RegisterVerifierModule` +- `UpdateMemoryCell` +- `OpenChallenge` +- `ResolveChallenge` +- `FinalizeWorkReceipt` - `AnchorBatchToBasePlaceholder` - `ImportFlowPulseObservation` - `ImportVerifierReport` +## Local Lifecycle Rules + +- Agent and model records are identity/provenance records only; they do not hold balances. +- 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. +- A failed, invalid, rejected, unsupported, reorged, missing, or still-unaccepted receipt cannot update memory. +- Challenges can be opened only against existing receipts. +- Finality receipts can be created only for accepted receipts with no unresolved challenge. +- Artifact availability is a local proof/status record over an existing artifact commitment; it does not store raw artifact data. +- Verifier modules are local identity records for verifier provenance; they do not introduce staking, rewards, or verifier economics. + ## Blocks And Roots Each block has: @@ -148,6 +220,8 @@ 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. + ## Persistence Default local state: @@ -165,9 +239,14 @@ Generated exports: - `fixtures/handoff/generated/dashboard-state.json` - `fixtures/handoff/generated/indexer-handoff.json` - `fixtures/handoff/generated/verifier-handoff.json` +- `fixtures/handoff/generated/control-plane-handoff.json` +- `fixtures/handoff/generated/genesis-config.json` +- `fixtures/handoff/generated/operator-key-references.json` - `fixtures/handoff/generated/state.json` -These are local prototype outputs. Review before committing generated copies. +The generated dashboard, indexer, verifier, and state outputs include the expanded local object maps and deterministic map roots. These are local prototype outputs. Review before committing generated copies. + +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. ## Non-Goals diff --git a/fixtures/handoff/README.md b/fixtures/handoff/README.md index 99e5b7d3..766e0365 100644 --- a/fixtures/handoff/README.md +++ b/fixtures/handoff/README.md @@ -7,12 +7,16 @@ Committed examples: - `sample-txs.json`: local transaction fixture for the Rust devnet. - `sample-flowpulse-observation.json`: synthetic FlowPulse observation import fixture. - `sample-verifier-report.json`: synthetic verifier report import fixture. +- `local-operator-key-reference.json`: local operator/worker/verifier key reference boundary with no signing secret. Generated examples: - `generated/dashboard-state.json` - `generated/indexer-handoff.json` - `generated/verifier-handoff.json` +- `generated/control-plane-handoff.json` +- `generated/genesis-config.json` +- `generated/operator-key-references.json` - `generated/state.json` Generated outputs are produced by: diff --git a/fixtures/handoff/local-operator-key-reference.json b/fixtures/handoff/local-operator-key-reference.json new file mode 100644 index 00000000..bdc5b86b --- /dev/null +++ b/fixtures/handoff/local-operator-key-reference.json @@ -0,0 +1,16 @@ +{ + "schema": "flowmemory.local_devnet.operator_key_reference.v0", + "description": "Deterministic local operator key reference boundary. This file contains no signing secret material or production key.", + "keyReferenceId": "operator-key:local-devnet:alpha", + "operatorId": "0x06739c78255ec573518e97ffa9d2c5e11f49d49e0c65217c77d710a558a57f21", + "workerKeyId": "0xc4cc0a4e778d201e59a442e969596ef8758fa62eb72c7ae4cb468c5493fc924d", + "verifierKeyId": "0xeaead587bf631e8926cf1a88ea5404f2211a339b77be7b9ffc08be420ce85551", + "verifierSetRoot": "0xbecddfb2cac22961206303e4f1255f58786e62503fbd54d875be915b68cc9635", + "signatureScheme": "eip712-secp256k1-fixture-digest-only", + "publicKeyHint": "local fixture boundary; no public key registry is implemented", + "secretMaterialBoundary": "no signing secret material is stored in devnet state or handoff output", + "cryptoSchemaRefs": [ + "crypto/FLOWMEMORY_CRYPTO_SPEC.md#domain-separation", + "crypto/ATTESTATIONS.md#local-signature-helpers" + ] +} diff --git a/fixtures/handoff/sample-txs.json b/fixtures/handoff/sample-txs.json index c1f30215..a591ce10 100644 --- a/fixtures/handoff/sample-txs.json +++ b/fixtures/handoff/sample-txs.json @@ -1,6 +1,6 @@ { "schema": "flowmemory.local_devnet.fixture.txs.v0", - "description": "No-value local FlowMemory transaction fixture.", + "description": "No-value local FlowMemory transaction fixture covering the full smoke lifecycle.", "txs": [ { "type": "RegisterRootfield", @@ -9,6 +9,29 @@ "schemaHash": "0x0d05a0ad7f9c8650e1f9b6f92a9714d7e9b7c29fcd067a8e3d48ccf8a84d1e7a", "metadataHash": "0x2b49f44f3d7f2a97970cc7ee3cb3cb9e5db4c4ab65f9fd797f0c703275c9eabc" }, + { + "type": "RegisterModelPassport", + "modelPassportId": "model:fixture:alpha", + "issuer": "operator:fixture", + "modelFamily": "local-fixture-model", + "modelHash": "0x94fb2b5e8a9712d2cf2475ff2ef301549f39778533cab5fa5b76b82d0011d635", + "metadataHash": "0xff7549bf123d6c6211ed280f87624aa51b98cc73bba0f1db26db345b7fed402d" + }, + { + "type": "RegisterAgent", + "agentId": "agent:fixture:alpha", + "controller": "operator:fixture", + "modelPassportId": "model:fixture:alpha", + "metadataHash": "0x6b4c7e0ad82f80a60ef758f760ef39f752157c45a889d27a197b0c6a225f9ccb" + }, + { + "type": "RegisterVerifierModule", + "verifierId": "verifier:fixture", + "operator": "operator:fixture", + "moduleHash": "0x11328344d17342875d060ba3e42032be3ee9469ba2f61fba411c7d233fd1d336", + "ruleSet": "flowmemory.work.rule_set.fixture.v0", + "metadataHash": "0xd9687f64d6ee634b8801ce7d42647742658de9ec07063e8aa64953f5aa0576ab" + }, { "type": "SubmitArtifactCommitment", "artifactId": "artifact:fixture:001", @@ -16,6 +39,15 @@ "commitment": "0xd09d2dbcb9447a778f30076fb1c42d9a5d1ef9cdaea43d68f72de06abf4f4b7f", "uriHint": "fixture://artifact/fixture/001" }, + { + "type": "MarkArtifactAvailability", + "proofId": "availability:fixture:001", + "artifactId": "artifact:fixture:001", + "rootfieldId": "rootfield:fixture:alpha", + "proofDigest": "0x6aee0c0b0a23d6859b1c295a490c857cf64a3ecfcb4cfc9e5eb888c2b6b75fda", + "storageBackend": "fixture-local", + "status": "available" + }, { "type": "CommitRoot", "rootfieldId": "rootfield:fixture:alpha", @@ -43,6 +75,36 @@ "status": "verified", "reasonCodes": [] }, + { + "type": "UpdateMemoryCell", + "memoryCellId": "memory:fixture:agent-alpha:core", + "agentId": "agent:fixture:alpha", + "rootfieldId": "rootfield:fixture:alpha", + "sourceReceiptId": "receipt:fixture:001", + "newRoot": "0xf1aa3ba90f90cefc00e3f65e0f556817245172a6f535c3f2f72f8fa7b41e5937", + "memoryDeltaRoot": "0x63da2313068c53548f0bc5f184ef0a7b2855d168ed3bef22ad006e8ceda1190f" + }, + { + "type": "OpenChallenge", + "challengeId": "challenge:fixture:001", + "receiptId": "receipt:fixture:001", + "challenger": "reviewer:fixture", + "evidenceHash": "0x4022c35817a9d660d32335960bda5d17c5630f4c2e47b6ec9c475e24a58b157d", + "reasonCode": "fixture-review" + }, + { + "type": "ResolveChallenge", + "challengeId": "challenge:fixture:001", + "resolver": "verifier:fixture", + "resolution": "dismissed" + }, + { + "type": "FinalizeWorkReceipt", + "finalityReceiptId": "finality:fixture:001", + "receiptId": "receipt:fixture:001", + "finalizedBy": "operator:fixture", + "finalityStatus": "finalized" + }, { "type": "AnchorBatchToBasePlaceholder", "appchainChainId": "flowmemory-local-devnet-v0",