From 74ee9130c00747df79fb6958472c54814140ce8e Mon Sep 17 00:00:00 2001 From: FlowMemory HQ Agent Date: Thu, 14 May 2026 13:42:16 -0500 Subject: [PATCH] Add production L1 consensus implementation snapshot --- crates/flowmemory-devnet/src/cli.rs | 992 ++++++++- crates/flowmemory-devnet/src/lib.rs | 30 +- crates/flowmemory-devnet/src/model.rs | 1924 ++++++++++++++++- .../flowmemory-devnet/tests/devnet_tests.rs | 625 +++++- devnet/README.md | 2 + ...14-flowchain-private-local-consensus-v0.md | 54 + docs/LOCAL_DEVNET.md | 84 +- .../BLOCK_VALIDATION_PROOF.md | 40 + .../BRIDGE_FINALITY_PROOF.md | 32 + .../production-l1-consensus/CHECKLIST.md | 21 + .../production-l1-consensus/EXPERIMENTS.md | 9 + .../production-l1-consensus/FINALITY_PROOF.md | 25 + .../FORK_CHOICE_PROOF.md | 22 + .../production-l1-consensus/HANDOFF.md | 56 + .../LIVE_L1_CONSENSUS_FINALITY.md | 87 + .../production-l1-consensus/NOTES.md | 18 + .../production-l1-consensus/PLAN.md | 31 + .../production-l1-consensus/VALIDATOR_SET.md | 31 + .../flowchain-live-l1-consensus-verify.ps1 | 30 + infra/scripts/flowchain-no-secret-scan.ps1 | 110 + infra/scripts/flowchain-node-start.ps1 | 125 ++ infra/scripts/flowchain-production-l1-e2e.ps1 | 114 + package.json | 5 + 23 files changed, 4359 insertions(+), 108 deletions(-) create mode 100644 docs/DECISIONS/2026-05-14-flowchain-private-local-consensus-v0.md create mode 100644 docs/agent-runs/production-l1-consensus/BLOCK_VALIDATION_PROOF.md create mode 100644 docs/agent-runs/production-l1-consensus/BRIDGE_FINALITY_PROOF.md create mode 100644 docs/agent-runs/production-l1-consensus/CHECKLIST.md create mode 100644 docs/agent-runs/production-l1-consensus/EXPERIMENTS.md create mode 100644 docs/agent-runs/production-l1-consensus/FINALITY_PROOF.md create mode 100644 docs/agent-runs/production-l1-consensus/FORK_CHOICE_PROOF.md create mode 100644 docs/agent-runs/production-l1-consensus/HANDOFF.md create mode 100644 docs/agent-runs/production-l1-consensus/LIVE_L1_CONSENSUS_FINALITY.md create mode 100644 docs/agent-runs/production-l1-consensus/NOTES.md create mode 100644 docs/agent-runs/production-l1-consensus/PLAN.md create mode 100644 docs/agent-runs/production-l1-consensus/VALIDATOR_SET.md create mode 100644 infra/scripts/flowchain-live-l1-consensus-verify.ps1 create mode 100644 infra/scripts/flowchain-no-secret-scan.ps1 create mode 100644 infra/scripts/flowchain-node-start.ps1 create mode 100644 infra/scripts/flowchain-production-l1-e2e.ps1 diff --git a/crates/flowmemory-devnet/src/cli.rs b/crates/flowmemory-devnet/src/cli.rs index 9f92b9fe..37838f2f 100644 --- a/crates/flowmemory-devnet/src/cli.rs +++ b/crates/flowmemory-devnet/src/cli.rs @@ -1,19 +1,25 @@ -use crate::hash::{hash_json, normalize_value}; +use crate::hash::{hash_json, keccak_hex, normalize_value}; use crate::model::{ - FLOWPULSE_TOPIC0, ImportedFlowPulseObservation, ImportedVerifierReport, LocalAuthorization, - Transaction, build_block, demo_transactions, envelope_tx, genesis_state, - product_demo_transactions, queue_authorized_transaction, queue_transaction, state_map_roots, - state_root, + BridgeLifecycleEvidence, FLOWPULSE_TOPIC0, ImportedFlowPulseObservation, + ImportedVerifierReport, LOCAL_PRIVATE_AUTHORITY_SET_ID, LOCAL_PRIVATE_VALIDATOR_ID, + LocalAuthorization, Transaction, ZERO_HASH, build_block, choose_canonical_fork, + consensus_state_root, demo_transactions, envelope_tx, finalized_hash, finalized_height, + finalized_state_root, genesis_state, product_demo_transactions, propose_block, + queue_authorized_transaction, queue_transaction, record_block_proposal_validation, + record_block_validation, record_duplicate_proposal_evidence, record_fork_choice_evidence, + state_map_roots, state_root, validate_bridge_lifecycle_evidence, validate_chain, }; use crate::storage::{default_state_path, load_or_genesis, load_state, reset_state, save_state}; use anyhow::{Context, Result, anyhow}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::BTreeMap; use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::thread; use std::time::Duration; +use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Debug)] pub struct Cli { @@ -77,6 +83,24 @@ pub enum Command { ProductSmoke { out_dir: PathBuf, }, + ConsensusValidate, + ValidatorSet, + FinalityStatus, + ForkChoiceTest { + out: Option, + }, + WriteFinalityProof { + out: PathBuf, + }, + ConsensusSmoke { + out_dir: PathBuf, + }, + LiveL1ConsensusReport { + out_dir: PathBuf, + }, + VerifyLiveL1Consensus { + out_dir: PathBuf, + }, } pub fn run_cli() -> Result<()> { @@ -196,6 +220,32 @@ fn parse_args(args: Vec) -> Result { .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("fixtures/handoff/generated-product")), }, + "consensus-validate" | "validate-chain" => Command::ConsensusValidate, + "validator-set" => Command::ValidatorSet, + "finality-status" => Command::FinalityStatus, + "fork-choice-test" => Command::ForkChoiceTest { + out: option_value_optional(&positional[1..], "--out").map(PathBuf::from), + }, + "write-finality-proof" => Command::WriteFinalityProof { + out: option_value(&positional[1..], "--out") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("devnet/local/finality-proof.json")), + }, + "consensus-smoke" => Command::ConsensusSmoke { + out_dir: option_value(&positional[1..], "--out-dir") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("devnet/local/consensus-smoke")), + }, + "live-l1-consensus-report" => Command::LiveL1ConsensusReport { + out_dir: option_value(&positional[1..], "--out-dir") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("devnet/local/live-l1-consensus")), + }, + "live-l1-consensus-verify" | "verify-live-l1-consensus" => Command::VerifyLiveL1Consensus { + out_dir: option_value(&positional[1..], "--out-dir") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("devnet/local/live-l1-consensus")), + }, unknown => return Err(anyhow!("unknown command '{unknown}'")), }; @@ -240,7 +290,7 @@ fn option_u64(args: &[String], name: &str) -> Result> { fn print_help() { println!( - "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 product-demo|product-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 product-demo|product-smoke [--out-dir ]\n consensus-validate|validate-chain\n validator-set\n finality-status\n fork-choice-test [--out ]\n write-finality-proof --out \n consensus-smoke [--out-dir ]\n live-l1-consensus-report [--out-dir ]\n live-l1-consensus-verify [--out-dir ]\n" ); } @@ -470,6 +520,64 @@ fn run(cli: Cli) -> Result<()> { deterministic_replay, ))?; } + Command::ConsensusValidate => { + let state = load_or_genesis(&cli.state)?; + let report = validate_chain(&state); + print_json(&report)?; + if !report.valid { + return Err(anyhow!("consensus validation failed")); + } + } + Command::ValidatorSet => { + let state = load_or_genesis(&cli.state)?; + print_json(&ValidatorSetSummary::from_state(&state))?; + } + Command::FinalityStatus => { + let state = load_or_genesis(&cli.state)?; + print_json(&FinalityStatusSummary::from_state(&state))?; + } + Command::ForkChoiceTest { out } => { + let proof = build_fork_choice_proof(); + if let Some(path) = out { + write_json(path, &proof)?; + } + print_json(&proof)?; + } + Command::WriteFinalityProof { out } => { + let state = load_or_genesis(&cli.state)?; + let proof = FinalityProofOutput::from_state(&state); + write_json(out.clone(), &proof)?; + print_json(&FinalityProofSummary { + schema: "flowmemory.local_devnet.finality_proof_summary.v0".to_string(), + out, + finalized_height: proof.finalized_height, + finalized_hash: proof.finalized_hash, + finalized_state_root: proof.finalized_state_root, + })?; + } + Command::ConsensusSmoke { out_dir } => { + let smoke = run_consensus_smoke(&out_dir)?; + save_state(&cli.state, &smoke.state)?; + write_runtime_boundary_files(&cli.state, &smoke.state)?; + export_handoff(&smoke.state, &out_dir.join("handoff"))?; + print_json(&smoke.report)?; + } + Command::LiveL1ConsensusReport { out_dir } => { + let state = load_or_genesis(&cli.state)?; + let output = write_live_l1_consensus_report(&state, &out_dir)?; + print_json(&output.report)?; + } + Command::VerifyLiveL1Consensus { out_dir } => { + let state = load_or_genesis(&cli.state)?; + let verification = verify_live_l1_consensus_report(&state, &out_dir)?; + print_json(&verification)?; + if verification.status != "passed" { + return Err(anyhow!( + "live L1 consensus readiness verification failed: {}", + verification.reason + )); + } + } } Ok(()) } @@ -611,7 +719,9 @@ fn run_node(options: NodeRunOptions) -> Result<()> { "blockNumber": block.block_number, "blockHash": block.block_hash, "txs": block.tx_ids.len(), - "stateRoot": block.state_root + "stateRoot": block.state_root, + "finalizedHeight": finalized_height(&state), + "finalizedHash": finalized_hash(&state) }))? ); @@ -804,6 +914,9 @@ fn sync_from_peers( if peer_state.chain_id != state.chain_id { continue; } + if !validate_chain(&peer_state).valid { + 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(); @@ -829,7 +942,8 @@ fn should_adopt_peer_state( return true; } if peer_height == local_height && peer_height > 0 { - return state_root(peer) < state_root(local); + return peer.consensus_state.canonical_head_hash + < local.consensus_state.canonical_head_hash; } false } @@ -849,6 +963,8 @@ fn write_node_identity( "mode": "local-file-private-testnet", "statePath": state_path, "peerConfig": peer_config, + "authoritySet": "private-local-authority-set", + "validatorSetSource": "genesis validator metadata; public only", "localOnly": true, "lanMode": "not exposed; static local-file peers only" }), @@ -1103,6 +1219,13 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "schema": "flowmemory.dashboard_state.local_devnet.v0", "genesisConfig": state.config, "operatorKeyReferences": state.operator_key_references, + "validatorSet": state.validator_set, + "authoritySet": state.authority_set, + "consensusState": state.consensus_state, + "chainFinalityReceipts": state.chain_finality_receipts, + "forkEvidence": state.fork_evidence, + "misbehaviorEvidence": state.misbehavior_evidence, + "consensusStateRoot": consensus_state_root(state), "stateRoot": state_root(state), "mapRoots": map_roots, "blockHeight": state.blocks.len(), @@ -1127,6 +1250,8 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "verifierModules": state.verifier_modules, "workReceipts": state.work_receipts, "verifierReports": state.verifier_reports, + "bridgeReplayKeys": state.bridge_replay_keys, + "bridgeCredits": state.bridge_credits, "baseAnchors": state.base_anchors, }); @@ -1135,6 +1260,12 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "genesisConfig": state.config, "importedObservations": state.imported_observations, "operatorKeyReferences": state.operator_key_references, + "validatorSet": state.validator_set, + "authoritySet": state.authority_set, + "consensusState": state.consensus_state, + "chainFinalityReceipts": state.chain_finality_receipts, + "forkEvidence": state.fork_evidence, + "misbehaviorEvidence": state.misbehavior_evidence, "agentAccounts": state.agent_accounts, "localTestUnitBalances": state.local_test_unit_balances, "faucetRecords": state.faucet_records, @@ -1150,15 +1281,24 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "challenges": state.challenges, "finalityReceipts": state.finality_receipts, "artifactAvailabilityProofs": state.artifact_availability_proofs, + "bridgeReplayKeys": state.bridge_replay_keys, + "bridgeCredits": state.bridge_credits, "blocks": state.blocks, "mapRoots": state_map_roots(state), "stateRoot": state_root(state), + "consensusStateRoot": consensus_state_root(state), }); let verifier = serde_json::json!({ "schema": "flowmemory.verifier_handoff.local_devnet.v0", "genesisConfig": state.config, "operatorKeyReferences": state.operator_key_references, + "validatorSet": state.validator_set, + "authoritySet": state.authority_set, + "consensusState": state.consensus_state, + "chainFinalityReceipts": state.chain_finality_receipts, + "forkEvidence": state.fork_evidence, + "misbehaviorEvidence": state.misbehavior_evidence, "localTestUnitBalances": state.local_test_unit_balances, "faucetRecords": state.faucet_records, "balanceTransfers": state.balance_transfers, @@ -1175,17 +1315,35 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "challenges": state.challenges, "finalityReceipts": state.finality_receipts, "artifactAvailabilityProofs": state.artifact_availability_proofs, + "bridgeReplayKeys": state.bridge_replay_keys, + "bridgeCredits": state.bridge_credits, "importedVerifierReports": state.imported_verifier_reports, "mapRoots": state_map_roots(state), "stateRoot": state_root(state), + "consensusStateRoot": consensus_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, + "validatorSet": state.validator_set, + "authoritySet": state.authority_set, "chainId": state.chain_id, "stateRoot": state_root(state), + "consensusStateRoot": consensus_state_root(state), + "consensus": { + "state": state.consensus_state, + "finalityReceipts": state.chain_finality_receipts, + "forkEvidence": state.fork_evidence, + "misbehaviorEvidence": state.misbehavior_evidence + }, + "finality": { + "finalizedHeight": finalized_height(state), + "finalizedHash": finalized_hash(state), + "finalizedStateRoot": finalized_state_root(state), + "latestFinalityReceiptId": state.consensus_state.latest_finality_receipt_id + }, "mapRoots": state_map_roots(state), "latestBlock": state.blocks.last(), "blocks": state.blocks, @@ -1212,6 +1370,8 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "verifierModules": state.verifier_modules, "workReceipts": state.work_receipts, "verifierReports": state.verifier_reports, + "bridgeReplayKeys": state.bridge_replay_keys, + "bridgeCredits": state.bridge_credits, "baseAnchors": state.base_anchors } }); @@ -1225,6 +1385,15 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() out_dir.join("operator-key-references.json"), &state.operator_key_references, )?; + write_json( + out_dir.join("validator-set.json"), + &ValidatorSetSummary::from_state(state), + )?; + write_json(out_dir.join("consensus-state.json"), &state.consensus_state)?; + write_json( + out_dir.join("finality-status.json"), + &FinalityStatusSummary::from_state(state), + )?; write_json(out_dir.join("state.json"), state)?; Ok(()) } @@ -1236,6 +1405,15 @@ fn write_runtime_boundary_files(state_path: &Path, state: &crate::model::ChainSt out_dir.join("operator-key-references.json"), &state.operator_key_references, )?; + write_json( + out_dir.join("validator-set.json"), + &ValidatorSetSummary::from_state(state), + )?; + write_json(out_dir.join("consensus-state.json"), &state.consensus_state)?; + write_json( + out_dir.join("finality-status.json"), + &FinalityStatusSummary::from_state(state), + )?; Ok(()) } @@ -1269,6 +1447,727 @@ fn string_at(values: &[Value], index: usize, label: &str) -> Result { .ok_or_else(|| anyhow!("missing string value {label}")) } +#[derive(Debug)] +struct ConsensusSmokeRun { + state: crate::model::ChainState, + report: ConsensusSmokeReport, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ConsensusSmokeReport { + schema: String, + validator_set: serde_json::Value, + finalized_height: u64, + finalized_hash: String, + finalized_state_root: String, + canonical_head: String, + consensus_state_root: String, + rejected_forks: Vec, + misbehavior_evidence: Vec, + bridge_replay_key: String, + bridge_replay_final: bool, + validation: crate::model::ConsensusValidationReport, + command_evidence: Vec, + output_files: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ValidatorSetSummary { + schema: String, + authority_set: crate::model::AuthoritySet, + validators: BTreeMap, + validator_set_root: String, + authority_set_root: String, +} + +impl ValidatorSetSummary { + fn from_state(state: &crate::model::ChainState) -> Self { + let roots = state_map_roots(state); + Self { + schema: "flowmemory.local_devnet.validator_set_summary.v0".to_string(), + authority_set: state.authority_set.clone(), + validators: state.validator_set.clone(), + validator_set_root: roots.validator_set_root, + authority_set_root: roots.authority_set_root, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct FinalityStatusSummary { + schema: String, + finalized_height: u64, + finalized_hash: String, + finalized_state_root: String, + canonical_height: u64, + canonical_head_hash: String, + latest_finality_receipt_id: Option, + consensus_state_root: String, +} + +impl FinalityStatusSummary { + fn from_state(state: &crate::model::ChainState) -> Self { + Self { + schema: "flowmemory.local_devnet.finality_status.v0".to_string(), + finalized_height: finalized_height(state), + finalized_hash: finalized_hash(state), + finalized_state_root: finalized_state_root(state), + canonical_height: state.consensus_state.canonical_height, + canonical_head_hash: state.consensus_state.canonical_head_hash.clone(), + latest_finality_receipt_id: state.consensus_state.latest_finality_receipt_id.clone(), + consensus_state_root: consensus_state_root(state), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct FinalityProofOutput { + schema: String, + consensus_state: crate::model::ConsensusState, + latest_finality_receipt: Option, + finalized_height: u64, + finalized_hash: String, + finalized_state_root: String, + consensus_state_root: String, +} + +impl FinalityProofOutput { + fn from_state(state: &crate::model::ChainState) -> Self { + let latest_finality_receipt = state + .consensus_state + .latest_finality_receipt_id + .as_ref() + .and_then(|id| state.chain_finality_receipts.get(id)) + .cloned(); + Self { + schema: "flowmemory.local_devnet.finality_proof.v0".to_string(), + consensus_state: state.consensus_state.clone(), + latest_finality_receipt, + finalized_height: finalized_height(state), + finalized_hash: finalized_hash(state), + finalized_state_root: finalized_state_root(state), + consensus_state_root: consensus_state_root(state), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct FinalityProofSummary { + schema: String, + out: PathBuf, + finalized_height: u64, + finalized_hash: String, + finalized_state_root: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ForkChoiceProof { + schema: String, + parent_hash: String, + candidate_hashes: Vec, + canonical_head: String, + rejected_forks: Vec, + deterministic_tie_breaker: String, +} + +fn run_consensus_smoke(out_dir: &Path) -> Result { + fs::create_dir_all(out_dir)?; + let mut state = genesis_state(); + queue_transaction( + &mut state, + consensus_rootfield_tx("rootfield:consensus:accepted"), + ); + let _accepted = build_block(&mut state); + + let fork_proof = build_fork_choice_proof(); + let fork_parent = genesis_state(); + let proposal_a = propose_block( + &fork_parent, + vec![envelope_tx(consensus_rootfield_tx("rootfield:fork:a"))], + LOCAL_PRIVATE_VALIDATOR_ID, + )?; + let proposal_b = propose_block( + &fork_parent, + vec![envelope_tx(consensus_rootfield_tx("rootfield:fork:b"))], + LOCAL_PRIVATE_VALIDATOR_ID, + )?; + record_duplicate_proposal_evidence(&mut state, &proposal_a.block, &proposal_b.block); + let outcome = choose_canonical_fork( + &fork_parent, + &[proposal_a.block.clone(), proposal_b.block.clone()], + ); + record_fork_choice_evidence(&mut state, &outcome); + + let valid_next = propose_block( + &state, + vec![envelope_tx(consensus_rootfield_tx( + "rootfield:consensus:next", + ))], + LOCAL_PRIVATE_VALIDATOR_ID, + )? + .block; + let mut wrong_parent = valid_next.clone(); + wrong_parent.parent_hash = keccak_hex(b"wrong-parent"); + let _ = record_block_validation(&mut state, &wrong_parent); + let mut wrong_genesis = valid_next.clone(); + wrong_genesis.genesis_hash = keccak_hex(b"wrong-genesis"); + let _ = record_block_validation(&mut state, &wrong_genesis); + let mut invalid_proposer = valid_next.clone(); + invalid_proposer.proposer_id = "validator:local-private:intruder".to_string(); + let _ = record_block_validation(&mut state, &invalid_proposer); + let mut invalid_root = propose_block( + &state, + vec![envelope_tx(consensus_rootfield_tx( + "rootfield:consensus:bad-root", + ))], + LOCAL_PRIVATE_VALIDATOR_ID, + )?; + invalid_root.block.state_root = keccak_hex(b"invalid-state-root"); + let _ = record_block_proposal_validation(&mut state, &invalid_root); + + let replay_key = "bridge-replay:consensus-smoke:001".to_string(); + queue_transaction(&mut state, bridge_replay_tx(&replay_key)); + let _bridge_block = build_block(&mut state); + queue_transaction(&mut state, bridge_replay_tx(&replay_key)); + let _duplicate_bridge_block = build_block(&mut state); + let _empty_finality_block = build_block(&mut state); + + let validation = validate_chain(&state); + let finality_proof = FinalityProofOutput::from_state(&state); + let snapshot = out_dir.join("state-snapshot.json"); + let report_path = out_dir.join("consensus-report.json"); + let finality_proof_path = out_dir.join("finality-proof.json"); + let fork_proof_path = out_dir.join("fork-choice-proof.json"); + + write_json(snapshot.clone(), &state)?; + let imported = load_state(&snapshot)?; + if imported.consensus_state.finalized_height != state.consensus_state.finalized_height + || imported.consensus_state.finalized_state_root + != state.consensus_state.finalized_state_root + { + return Err(anyhow!("consensus snapshot did not preserve finality")); + } + write_json(finality_proof_path.clone(), &finality_proof)?; + write_json(fork_proof_path.clone(), &fork_proof)?; + + let report = ConsensusSmokeReport { + schema: "flowmemory.local_devnet.consensus_smoke_report.v0".to_string(), + validator_set: serde_json::json!({ + "authoritySet": state.authority_set, + "validators": state.validator_set + }), + finalized_height: finalized_height(&state), + finalized_hash: finalized_hash(&state), + finalized_state_root: finalized_state_root(&state), + canonical_head: state.consensus_state.canonical_head_hash.clone(), + consensus_state_root: consensus_state_root(&state), + rejected_forks: state.fork_evidence.clone(), + misbehavior_evidence: state.misbehavior_evidence.clone(), + bridge_replay_key: replay_key.clone(), + bridge_replay_final: crate::model::bridge_replay_key_is_final(&state, &replay_key), + validation, + command_evidence: vec![ + "cargo test --manifest-path crates/flowmemory-devnet/Cargo.toml".to_string(), + "cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- consensus-smoke" + .to_string(), + "npm run flowchain:consensus:smoke".to_string(), + ], + output_files: vec![ + report_path.to_string_lossy().to_string(), + finality_proof_path.to_string_lossy().to_string(), + fork_proof_path.to_string_lossy().to_string(), + snapshot.to_string_lossy().to_string(), + ], + }; + write_json(report_path, &report)?; + Ok(ConsensusSmokeRun { state, report }) +} + +fn build_fork_choice_proof() -> ForkChoiceProof { + let parent = genesis_state(); + let proposal_a = propose_block( + &parent, + vec![envelope_tx(consensus_rootfield_tx( + "rootfield:fork-choice:a", + ))], + LOCAL_PRIVATE_VALIDATOR_ID, + ) + .expect("fork-choice proposal A"); + let proposal_b = propose_block( + &parent, + vec![envelope_tx(consensus_rootfield_tx( + "rootfield:fork-choice:b", + ))], + LOCAL_PRIVATE_VALIDATOR_ID, + ) + .expect("fork-choice proposal B"); + let outcome = choose_canonical_fork( + &parent, + &[proposal_a.block.clone(), proposal_b.block.clone()], + ); + let canonical_head = outcome + .canonical_head + .as_ref() + .map(|block| block.block_hash.clone()) + .unwrap_or_else(|| ZERO_HASH.to_string()); + ForkChoiceProof { + schema: "flowmemory.local_devnet.fork_choice_proof.v0".to_string(), + parent_hash: parent.parent_hash, + candidate_hashes: vec![proposal_a.block.block_hash, proposal_b.block.block_hash], + canonical_head, + rejected_forks: outcome.rejected, + deterministic_tie_breaker: "highest height, then lexicographically lowest block hash" + .to_string(), + } +} + +fn consensus_rootfield_tx(rootfield_id: &str) -> Transaction { + Transaction::RegisterRootfield { + rootfield_id: rootfield_id.to_string(), + owner: "operator:consensus-smoke".to_string(), + schema_hash: keccak_hex(format!("schema:{rootfield_id}").as_bytes()), + metadata_hash: keccak_hex(format!("metadata:{rootfield_id}").as_bytes()), + } +} + +fn bridge_replay_tx(replay_key: &str) -> Transaction { + Transaction::RecordBridgeReplayKey { + replay_key: replay_key.to_string(), + source_chain_id: "base-sepolia-or-mock-local".to_string(), + source_tx_hash: keccak_hex(format!("source-tx:{replay_key}").as_bytes()), + source_log_index: "0".to_string(), + local_object_id: format!("bridge-object:{replay_key}"), + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LiveL1ConsensusOutput { + report_path: PathBuf, + bridge_lifecycle_evidence_path: PathBuf, + report: LiveL1ConsensusReport, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LiveL1ConsensusReport { + schema: String, + generated_at_unix_seconds: String, + current_consensus_mode: String, + consensus_mode_detail: String, + validator_set_source: String, + authority_set_id: String, + validators: Vec, + block_signing_status: LiveBlockSigningStatus, + finality_rule: LiveFinalityRuleReport, + fork_choice: LiveForkChoiceReport, + peer_mode: LivePeerModeReport, + bridge_lifecycle_evidence: LiveBridgeLifecycleReport, + private_live_pilot: LiveReadinessDecision, + public_l1: LiveReadinessDecision, + readiness_claims: LiveReadinessClaims, + source_truth_notes: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LiveValidatorReportEntry { + validator_id: String, + account_ref: String, + consensus_public_key: String, + consensus_key_id: String, + roles: Vec, + weight: u64, + active: bool, + key_scope: String, + bridge_key_separation: String, + wallet_key_separation: String, + secret_material_exported: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LiveBlockSigningStatus { + status: String, + latest_block_hash: String, + latest_block_height: u64, + proposer_id: String, + proof_type: String, + proof_digest: String, + signature_present: bool, + validation_status: String, + secret_material_exported: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LiveFinalityRuleReport { + rule: String, + finalized_height: u64, + finalized_hash: String, + finalized_state_root: String, + latest_finality_receipt_id: Option, + public_live_finality_claimed: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LiveForkChoiceReport { + rule: String, + blocked_reason_for_public_l1: String, + evidence_count: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LivePeerModeReport { + mode: String, + network_exposure: String, + public_peer_discovery: bool, + static_peer_sync_only: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LiveBridgeLifecycleReport { + evidence_path: PathBuf, + evidence_id: String, + credit_id: String, + credit_included_in_block: u64, + credit_block_hash: String, + state_root_changed_after_credit: bool, + finality_status: String, + transfer_after_credit_block: u64, + spendable_under_finality_rule: bool, + production_bridge_claimed: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LiveReadinessDecision { + acceptable: bool, + status: String, + reasons: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LiveReadinessClaims { + claims_public_live_finality: bool, + acceptable_for_private_live_pilot: bool, + acceptable_for_public_l1: bool, + production_ready: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LiveL1ConsensusVerifySummary { + schema: String, + status: String, + report_path: PathBuf, + bridge_lifecycle_evidence_path: PathBuf, + acceptable_for_private_live_pilot: bool, + acceptable_for_public_l1: bool, + reason: String, +} + +fn write_live_l1_consensus_report( + state: &crate::model::ChainState, + out_dir: &Path, +) -> Result { + fs::create_dir_all(out_dir)?; + let bridge_lifecycle_evidence_path = out_dir.join("bridge-lifecycle-evidence.json"); + let bridge_evidence = build_bridge_lifecycle_evidence_sample()?; + write_json(bridge_lifecycle_evidence_path.clone(), &bridge_evidence)?; + let report_path = out_dir.join("consensus-finality-report.json"); + let report = + LiveL1ConsensusReport::from_state(state, &bridge_lifecycle_evidence_path, &bridge_evidence); + write_json(report_path.clone(), &report)?; + Ok(LiveL1ConsensusOutput { + report_path, + bridge_lifecycle_evidence_path, + report, + }) +} + +fn verify_live_l1_consensus_report( + state: &crate::model::ChainState, + out_dir: &Path, +) -> Result { + fs::create_dir_all(out_dir)?; + let report_path = out_dir.join("consensus-finality-report.json"); + let bridge_lifecycle_evidence_path = out_dir.join("bridge-lifecycle-evidence.json"); + if report_path.exists() { + let existing = fs::read_to_string(&report_path) + .with_context(|| format!("failed to read {}", report_path.display()))?; + let existing_json: Value = serde_json::from_str(&existing) + .with_context(|| format!("invalid JSON in {}", report_path.display()))?; + if report_claims_public_live_finality(&existing_json) { + return Ok(LiveL1ConsensusVerifySummary { + schema: "flowmemory.local_devnet.live_l1_consensus_verify.v0".to_string(), + status: "failed".to_string(), + report_path, + bridge_lifecycle_evidence_path, + acceptable_for_private_live_pilot: false, + acceptable_for_public_l1: false, + reason: "existing report claims public/live L1 finality while this code is single-process local/private" + .to_string(), + }); + } + } + + let output = write_live_l1_consensus_report(state, out_dir)?; + let report_json = serde_json::to_value(&output.report)?; + let unsafe_claim = report_claims_public_live_finality(&report_json); + let status = if unsafe_claim { "failed" } else { "passed" }; + Ok(LiveL1ConsensusVerifySummary { + schema: "flowmemory.local_devnet.live_l1_consensus_verify.v0".to_string(), + status: status.to_string(), + report_path: output.report_path, + bridge_lifecycle_evidence_path: output.bridge_lifecycle_evidence_path, + acceptable_for_private_live_pilot: output.report.private_live_pilot.acceptable, + acceptable_for_public_l1: output.report.public_l1.acceptable, + reason: if unsafe_claim { + "generated report contains an unsafe public/live L1 finality claim".to_string() + } else { + "report is honest: private/local single-authority pilot only; public L1 blocked" + .to_string() + }, + }) +} + +fn report_claims_public_live_finality(value: &Value) -> bool { + bool_pointer(value, "/readinessClaims/claimsPublicLiveFinality") + || bool_pointer(value, "/readinessClaims/acceptableForPublicL1") + || bool_pointer(value, "/readinessClaims/productionReady") + || bool_pointer(value, "/publicL1/acceptable") + || bool_pointer(value, "/acceptableForPublicL1") + || bool_pointer(value, "/claimsPublicLiveFinality") + || bool_pointer(value, "/productionReady") +} + +fn bool_pointer(value: &Value, pointer: &str) -> bool { + value + .pointer(pointer) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +impl LiveL1ConsensusReport { + fn from_state( + state: &crate::model::ChainState, + bridge_lifecycle_evidence_path: &Path, + bridge_evidence: &BridgeLifecycleEvidence, + ) -> Self { + let validation = validate_chain(state); + let latest = state.blocks.last(); + let validators = state + .validator_set + .values() + .map(|validator| LiveValidatorReportEntry { + validator_id: validator.validator_id.clone(), + account_ref: validator.account_ref.clone(), + consensus_public_key: validator.consensus_public_key.clone(), + consensus_key_id: validator.consensus_key_id.clone(), + roles: validator.roles.clone(), + weight: validator.weight, + active: validator.active, + key_scope: validator.key_scope.clone(), + bridge_key_separation: validator.bridge_key_separation.clone(), + wallet_key_separation: validator.wallet_key_separation.clone(), + secret_material_exported: false, + }) + .collect::>(); + let block_signing_status = latest + .map(|block| LiveBlockSigningStatus { + status: "local-authority-proof-present".to_string(), + latest_block_hash: block.block_hash.clone(), + latest_block_height: block.block_number, + proposer_id: block.proposer_id.clone(), + proof_type: block.authority_proof.proof_type.clone(), + proof_digest: block.authority_proof.digest.clone(), + signature_present: !block.authority_proof.signature.is_empty() + && block.authority_proof.signature != ZERO_HASH, + validation_status: if validation.valid { + "valid".to_string() + } else { + "invalid".to_string() + }, + secret_material_exported: false, + }) + .unwrap_or_else(|| LiveBlockSigningStatus { + status: "no-block-produced-yet".to_string(), + latest_block_hash: state.parent_hash.clone(), + latest_block_height: 0, + proposer_id: LOCAL_PRIVATE_VALIDATOR_ID.to_string(), + proof_type: "none".to_string(), + proof_digest: ZERO_HASH.to_string(), + signature_present: false, + validation_status: if validation.valid { + "valid-genesis".to_string() + } else { + "invalid".to_string() + }, + secret_material_exported: false, + }); + Self { + schema: "flowmemory.local_devnet.live_l1_consensus_finality_report.v0".to_string(), + generated_at_unix_seconds: now_unix_seconds(), + current_consensus_mode: "single-process-private-local-authority-set".to_string(), + consensus_mode_detail: + "One local authority proposes, signs, validates, and immediately finalizes local blocks in this process. This is honest private/local pilot consensus, not public decentralization." + .to_string(), + validator_set_source: + "crates/flowmemory-devnet/src/model.rs default genesis validator set; dashboard-safe public metadata only" + .to_string(), + authority_set_id: LOCAL_PRIVATE_AUTHORITY_SET_ID.to_string(), + validators, + block_signing_status, + finality_rule: LiveFinalityRuleReport { + rule: state.consensus_state.finality_rule.clone(), + finalized_height: finalized_height(state), + finalized_hash: finalized_hash(state), + finalized_state_root: finalized_state_root(state), + latest_finality_receipt_id: state.consensus_state.latest_finality_receipt_id.clone(), + public_live_finality_claimed: false, + }, + fork_choice: LiveForkChoiceReport { + rule: + "valid highest height; tie-break lexicographically lowest canonical block hash for static local-file peer sync" + .to_string(), + blocked_reason_for_public_l1: + "No public peer discovery, no production BFT network, no staking/slashing, and only one local authority." + .to_string(), + evidence_count: state.fork_evidence.len(), + }, + peer_mode: LivePeerModeReport { + mode: "single-process-local-file-private".to_string(), + network_exposure: "not publicly exposed; optional static local-file peers only" + .to_string(), + public_peer_discovery: false, + static_peer_sync_only: true, + }, + bridge_lifecycle_evidence: LiveBridgeLifecycleReport { + evidence_path: bridge_lifecycle_evidence_path.to_path_buf(), + evidence_id: bridge_evidence.evidence_id.clone(), + credit_id: bridge_evidence.credit_id.clone(), + credit_included_in_block: bridge_evidence.credit_included_in_block, + credit_block_hash: bridge_evidence.credit_block_hash.clone(), + state_root_changed_after_credit: bridge_evidence.state_root_changed_after_credit, + finality_status: bridge_evidence.finality.status.clone(), + transfer_after_credit_block: bridge_evidence + .transfer_after_credit + .transfer_block_number, + spendable_under_finality_rule: bridge_evidence + .transfer_after_credit + .spendable_under_finality_rule, + production_bridge_claimed: false, + }, + private_live_pilot: LiveReadinessDecision { + acceptable: true, + status: "acceptable-with-private-local-scope".to_string(), + reasons: vec![ + "single local authority is explicit".to_string(), + "validator metadata exports public references only".to_string(), + "bridge credit spend evidence requires block inclusion, state-root change, authority proof, and local finality receipt".to_string(), + "network exposure is local/private only".to_string(), + ], + }, + public_l1: LiveReadinessDecision { + acceptable: false, + status: "blocked".to_string(), + reasons: vec![ + "single authority and single process".to_string(), + "no public validator onboarding or independent validator set".to_string(), + "no production BFT finality, peer discovery, staking, slashing, or audited cryptography".to_string(), + "bridge evidence is private/local pilot evidence and does not claim production bridge security".to_string(), + ], + }, + readiness_claims: LiveReadinessClaims { + claims_public_live_finality: false, + acceptable_for_private_live_pilot: true, + acceptable_for_public_l1: false, + production_ready: false, + }, + source_truth_notes: vec![ + "Requested production-l1 protocol schemas and GENESIS_PROOF.md are absent from this worktree and origin/main; sibling unmerged protocol worktree was read as supplemental context only.".to_string(), + "This report blocks public/live-L1 claims until those protocol artifacts and real multi-validator mechanics are merged and wired.".to_string(), + ], + } + } +} + +fn build_bridge_lifecycle_evidence_sample() -> Result { + let mut state = genesis_state(); + let receiver = "local-account:bridge:receiver"; + let sink = "local-account:bridge:sink"; + queue_transaction( + &mut state, + Transaction::CreateLocalTestUnitBalance { + account_id: sink.to_string(), + owner: "operator:bridge:sink".to_string(), + }, + ); + let _setup_block = build_block(&mut state); + + let credit_id = keccak_hex(b"flowmemory.live_l1.bridge.credit.001"); + let replay_key = keccak_hex(b"flowmemory.live_l1.bridge.replay.001"); + queue_transaction( + &mut state, + Transaction::ApplyBridgeCredit { + credit_id: credit_id.clone(), + replay_key, + source_chain_id: "8453".to_string(), + source_tx_hash: keccak_hex(b"flowmemory.live_l1.bridge.source_tx.001"), + source_log_index: "0".to_string(), + recipient_account_id: receiver.to_string(), + amount_units: 25, + evidence_hash: keccak_hex(b"flowmemory.live_l1.bridge.evidence.001"), + }, + ); + let credit_block = build_block(&mut state); + let finality_receipt_id = state + .consensus_state + .latest_finality_receipt_id + .clone() + .ok_or_else(|| anyhow!("credit block did not produce a finality receipt"))?; + let transfer_id = "bridge-spend:live-l1:001".to_string(); + let memo = format!( + "credit={credit_id};finality={finality_receipt_id};stateRoot={}", + credit_block.state_root + ); + queue_transaction( + &mut state, + Transaction::SpendBridgeCreditLocalTestUnits { + transfer_id: transfer_id.clone(), + credit_id: credit_id.clone(), + finality_receipt_id, + credited_block_hash: credit_block.block_hash, + credited_state_root: credit_block.state_root, + from_account_id: receiver.to_string(), + to_account_id: sink.to_string(), + amount_units: 10, + memo, + }, + ); + let _spend_block = build_block(&mut state); + validate_bridge_lifecycle_evidence(&state, &credit_id, &transfer_id, true) + .map_err(|error| anyhow!("bridge lifecycle evidence validation failed: {error}")) +} + +fn now_unix_seconds() -> String { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs().to_string()) + .unwrap_or_else(|_| "0".to_string()) +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct QueuedTransactions { @@ -1289,6 +2188,10 @@ struct NodeStatus { next_block_number: u64, latest_block_hash: String, state_root: String, + consensus_state_root: String, + finalized_height: u64, + finalized_hash: String, + finalized_state_root: String, pending_txs: usize, local_test_unit_balances: usize, faucet_records: usize, @@ -1300,6 +2203,8 @@ struct NodeStatus { lp_positions: usize, liquidity_receipts: usize, swap_receipts: usize, + bridge_replay_keys: usize, + bridge_credits: usize, static_peer_sync: Option, last_ingested_txs: usize, last_rejected_inbox_files: usize, @@ -1335,6 +2240,10 @@ impl NodeStatus { .map(|block| block.block_hash.clone()) .unwrap_or_else(|| state.parent_hash.clone()), state_root: state_root(state), + consensus_state_root: consensus_state_root(state), + finalized_height: finalized_height(state), + finalized_hash: finalized_hash(state), + finalized_state_root: finalized_state_root(state), pending_txs: state.pending_txs.len(), local_test_unit_balances: state.local_test_unit_balances.len(), faucet_records: state.faucet_records.len(), @@ -1346,6 +2255,8 @@ impl NodeStatus { lp_positions: state.lp_positions.len(), liquidity_receipts: state.liquidity_receipts.len(), swap_receipts: state.swap_receipts.len(), + bridge_replay_keys: state.bridge_replay_keys.len(), + bridge_credits: state.bridge_credits.len(), static_peer_sync, last_ingested_txs, last_rejected_inbox_files, @@ -1420,8 +2331,16 @@ struct StateSummary { logical_time: u64, parent_hash: String, state_root: String, + consensus_state_root: String, + finalized_height: u64, + finalized_hash: String, + finalized_state_root: String, map_roots: crate::model::StateMapRoots, operator_key_references: usize, + validators: usize, + chain_finality_receipts: usize, + fork_evidence: usize, + misbehavior_evidence: usize, pending_txs: usize, blocks: usize, rootfields: usize, @@ -1449,6 +2368,8 @@ struct StateSummary { imported_observations: usize, imported_verifier_reports: usize, base_anchors: usize, + bridge_replay_keys: usize, + bridge_credits: usize, } impl StateSummary { @@ -1460,8 +2381,16 @@ impl StateSummary { logical_time: state.logical_time, parent_hash: state.parent_hash.clone(), state_root: state_root(state), + consensus_state_root: consensus_state_root(state), + finalized_height: finalized_height(state), + finalized_hash: finalized_hash(state), + finalized_state_root: finalized_state_root(state), map_roots: state_map_roots(state), operator_key_references: state.operator_key_references.len(), + validators: state.validator_set.len(), + chain_finality_receipts: state.chain_finality_receipts.len(), + fork_evidence: state.fork_evidence.len(), + misbehavior_evidence: state.misbehavior_evidence.len(), pending_txs: state.pending_txs.len(), blocks: state.blocks.len(), rootfields: state.rootfields.len(), @@ -1489,6 +2418,8 @@ impl StateSummary { imported_observations: state.imported_observations.len(), imported_verifier_reports: state.imported_verifier_reports.len(), base_anchors: state.base_anchors.len(), + bridge_replay_keys: state.bridge_replay_keys.len(), + bridge_credits: state.bridge_credits.len(), } } } @@ -1501,6 +2432,8 @@ struct RunSummary { block_hashes: Vec, next_block_number: u64, state_root: String, + finalized_height: u64, + finalized_hash: String, } impl RunSummary { @@ -1511,6 +2444,8 @@ impl RunSummary { block_hashes: blocks.into_iter().map(|block| block.block_hash).collect(), next_block_number: state.next_block_number, state_root: state_root(state), + finalized_height: finalized_height(state), + finalized_hash: finalized_hash(state), } } } @@ -1521,6 +2456,10 @@ struct ExportSummary { schema: String, out_dir: PathBuf, state_root: String, + consensus_state_root: String, + finalized_height: u64, + finalized_hash: String, + finalized_state_root: String, map_roots: crate::model::StateMapRoots, files: Vec, } @@ -1531,6 +2470,10 @@ impl ExportSummary { schema: "flowmemory.local_devnet.export_summary.v0".to_string(), out_dir, state_root: state_root(state), + consensus_state_root: consensus_state_root(state), + finalized_height: finalized_height(state), + finalized_hash: finalized_hash(state), + finalized_state_root: finalized_state_root(state), map_roots: state_map_roots(state), files: handoff_files(), } @@ -1543,6 +2486,9 @@ struct ExportStateSummary { schema: String, out: PathBuf, state_root: String, + finalized_height: u64, + finalized_hash: String, + finalized_state_root: String, } impl ExportStateSummary { @@ -1551,6 +2497,9 @@ impl ExportStateSummary { schema: "flowmemory.local_devnet.export_state_summary.v0".to_string(), out, state_root: state_root(state), + finalized_height: finalized_height(state), + finalized_hash: finalized_hash(state), + finalized_state_root: finalized_state_root(state), } } } @@ -1562,6 +2511,9 @@ struct ImportStateSummary { from: PathBuf, state_path: PathBuf, state_root: String, + finalized_height: u64, + finalized_hash: String, + finalized_state_root: String, map_roots: crate::model::StateMapRoots, } @@ -1572,6 +2524,9 @@ impl ImportStateSummary { from, state_path, state_root: state_root(state), + finalized_height: finalized_height(state), + finalized_hash: finalized_hash(state), + finalized_state_root: finalized_state_root(state), map_roots: state_map_roots(state), } } @@ -1585,6 +2540,9 @@ struct DemoSummary { first_block_hash: String, second_block_hash: String, state_root: String, + finalized_height: u64, + finalized_hash: String, + finalized_state_root: String, agent_id: String, agent_registered: bool, local_balance_account_id: String, @@ -1613,6 +2571,9 @@ impl DemoSummary { first_block_hash: demo.first_block_hash.clone(), second_block_hash: demo.second_block_hash.clone(), state_root: state_root(&demo.state), + finalized_height: finalized_height(&demo.state), + finalized_hash: finalized_hash(&demo.state), + finalized_state_root: finalized_state_root(&demo.state), agent_id: "agent:demo:alpha".to_string(), agent_registered: demo.state.agent_accounts.contains_key("agent:demo:alpha"), local_balance_account_id: "local-balance:demo:agent-alpha".to_string(), @@ -1659,6 +2620,9 @@ struct SmokeSummary { state_root: String, block_height: usize, latest_block_hash: String, + finalized_height: u64, + finalized_hash: String, + finalized_state_root: String, deterministic_replay: bool, checks: SmokeChecks, handoff_files: Vec, @@ -1699,6 +2663,9 @@ impl SmokeSummary { state_root: state_root(&demo.state), block_height: demo.state.blocks.len(), latest_block_hash: demo.state.parent_hash.clone(), + finalized_height: finalized_height(&demo.state), + finalized_hash: finalized_hash(&demo.state), + finalized_state_root: finalized_state_root(&demo.state), deterministic_replay, checks: SmokeChecks { genesis_config_initialized: demo.state.config.no_value, @@ -1762,6 +2729,9 @@ struct ProductSmokeSummary { state_root: String, block_height: usize, latest_block_hash: String, + finalized_height: u64, + finalized_hash: String, + finalized_state_root: String, deterministic_replay: bool, checks: ProductSmokeChecks, handoff_files: Vec, @@ -1831,6 +2801,9 @@ impl ProductSmokeSummary { state_root: state_root(&demo.state), block_height: demo.state.blocks.len(), latest_block_hash: demo.state.parent_hash.clone(), + finalized_height: finalized_height(&demo.state), + finalized_hash: finalized_hash(&demo.state), + finalized_state_root: finalized_state_root(&demo.state), deterministic_replay, checks: ProductSmokeChecks { local_accounts_funded: alice_funded && bob_funded, @@ -1878,6 +2851,9 @@ fn handoff_files() -> Vec { "control-plane-handoff.json".to_string(), "genesis-config.json".to_string(), "operator-key-references.json".to_string(), + "validator-set.json".to_string(), + "consensus-state.json".to_string(), + "finality-status.json".to_string(), "state.json".to_string(), ] } diff --git a/crates/flowmemory-devnet/src/lib.rs b/crates/flowmemory-devnet/src/lib.rs index e2833206..08a385c5 100644 --- a/crates/flowmemory-devnet/src/lib.rs +++ b/crates/flowmemory-devnet/src/lib.rs @@ -6,15 +6,27 @@ pub mod storage; pub use cli::run_cli; pub use hash::{canonical_json, keccak_hex}; pub use model::{ - AgentAccount, ArtifactAvailabilityProof, BalanceTransfer, BaseAnchorPlaceholder, Block, - BlockReceipt, ChainState, Challenge, DevnetConfig, DevnetError, DexPool, FaucetRecord, - FinalityReceipt, ImportedFlowPulseObservation, ImportedVerifierReport, + AgentAccount, ArtifactAvailabilityProof, AuthorityProof, AuthoritySet, BalanceTransfer, + BaseAnchorPlaceholder, Block, BlockProposal, BlockReceipt, BridgeCreditFinalityEvidence, + BridgeCreditRecord, BridgeLifecycleEvidence, BridgeReplayKeyRecord, BridgeSpendEvidence, + ChainState, Challenge, ConsensusFinalityReceipt, ConsensusState, ConsensusValidationError, + ConsensusValidationReport, DevnetConfig, DevnetError, DexPool, FaucetRecord, + FinalityCertificate, FinalityReceipt, ForkChoiceOutcome, ForkEvidence, + ImportedFlowPulseObservation, ImportedVerifierReport, LOCAL_PRIVATE_VALIDATOR_ID, LOCAL_TEST_UNIT_ASSET_ID, LiquidityReceipt, LocalAuthorization, LocalTestToken, LocalTestTokenBalance, LocalTestTokenMintReceipt, LocalTestUnitBalance, LpPosition, MemoryCell, - ModelPassport, OperatorKeyReference, StateMapRoots, SwapReceipt, Transaction, TxEnvelope, - VerifierModule, apply_transaction, build_block, default_config, - default_operator_key_references, deterministic_liquidity_id, deterministic_lp_position_id, - deterministic_pool_id, deterministic_swap_id, deterministic_token_balance_id, - deterministic_token_id, deterministic_token_mint_id, genesis_state, product_demo_transactions, - queue_authorized_transaction, state_map_roots, state_root, + MisbehaviorEvidence, ModelPassport, OperatorKeyReference, StateMapRoots, SwapReceipt, + Transaction, TxEnvelope, ValidatorIdentity, ValidatorPublicMetadata, VerifierModule, + apply_transaction, bridge_credit_transaction_id, bridge_replay_key_is_final, build_block, + build_block_with_proposer, calculate_block_hash, choose_canonical_fork, commit_block_proposal, + consensus_state_root, default_authority_set, default_config, default_consensus_state, + default_operator_key_references, default_validator_set, deterministic_liquidity_id, + deterministic_lp_position_id, deterministic_pool_id, deterministic_swap_id, + deterministic_token_balance_id, deterministic_token_id, deterministic_token_mint_id, + expected_proposer_id, finality_receipt_for_block, finalized_hash, finalized_height, + finalized_state_root, genesis_state, product_demo_transactions, propose_block, + queue_authorized_transaction, record_block_proposal_validation, record_block_validation, + record_duplicate_proposal_evidence, record_fork_choice_evidence, state_map_roots, state_root, + validate_block_header, validate_block_proposal, validate_bridge_lifecycle_evidence, + validate_chain, }; diff --git a/crates/flowmemory-devnet/src/model.rs b/crates/flowmemory-devnet/src/model.rs index 86162690..232c20c9 100644 --- a/crates/flowmemory-devnet/src/model.rs +++ b/crates/flowmemory-devnet/src/model.rs @@ -1,6 +1,6 @@ use crate::hash::{hash_json, keccak_hex}; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use thiserror::Error; pub const STATE_SCHEMA: &str = "flowmemory.local_devnet.state.v0"; @@ -8,11 +8,25 @@ 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 VALIDATOR_IDENTITY_SCHEMA: &str = "flowmemory.local_devnet.validator_identity.v0"; +pub const AUTHORITY_SET_SCHEMA: &str = "flowmemory.local_devnet.authority_set.v0"; +pub const CONSENSUS_STATE_SCHEMA: &str = "flowmemory.local_devnet.consensus_state.v0"; +pub const CHAIN_FINALITY_RECEIPT_SCHEMA: &str = "flowmemory.local_devnet.chain_finality_receipt.v0"; +pub const FINALITY_CERTIFICATE_SCHEMA: &str = "flowmemory.local_devnet.finality_certificate.v0"; +pub const MISBEHAVIOR_EVIDENCE_SCHEMA: &str = "flowmemory.local_devnet.misbehavior_evidence.v0"; +pub const FORK_EVIDENCE_SCHEMA: &str = "flowmemory.local_devnet.fork_evidence.v0"; +pub const AUTHORITY_PROOF_SCHEMA: &str = "flowmemory.local_devnet.authority_proof.v0"; +pub const BRIDGE_REPLAY_KEY_SCHEMA: &str = "flowmemory.local_devnet.bridge_replay_key.v0"; +pub const BRIDGE_CREDIT_SCHEMA: &str = "flowmemory.local_devnet.bridge_credit.v0"; +pub const BRIDGE_LIFECYCLE_EVIDENCE_SCHEMA: &str = + "flowmemory.local_devnet.bridge_lifecycle_evidence.v0"; pub const GENESIS_HASH: &str = "0x0f23c892cbd2d00c10839d97ddab833698a83f8df8d6df27ceac03cfdd4b7bc9"; pub const ZERO_HASH: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; pub const FLOWPULSE_TOPIC0: &str = "0x5d07190b9ae441b4d7b16259a48424acd451492b12f5f99a29f5bfd992c13e43"; pub const LOCAL_TEST_UNIT_ASSET_ID: &str = "asset:flowchain-local-test-unit"; +pub const LOCAL_PRIVATE_VALIDATOR_ID: &str = "validator:local-private:alpha"; +pub const LOCAL_PRIVATE_AUTHORITY_SET_ID: &str = "authority-set:flowmemory-local-private-v0"; #[derive(Debug, Error, PartialEq, Eq)] pub enum DevnetError { @@ -138,6 +152,74 @@ pub enum DevnetError { AnchorAlreadyExists(String), #[error("invalid event signature: {0}")] InvalidEventSignature(String), + #[error("duplicate transaction id in block: {0}")] + DuplicateTransaction(String), + #[error("bridge replay key already used: {0}")] + BridgeReplayKeyAlreadyUsed(String), + #[error("bridge credit amount must be greater than zero: {0}")] + BridgeCreditAmountMustBePositive(String), + #[error("bridge credit already exists: {0}")] + BridgeCreditAlreadyExists(String), + #[error("bridge credit does not exist: {0}")] + BridgeCreditMissing(String), + #[error("bridge finality receipt does not exist: {0}")] + BridgeFinalityReceiptMissing(String), + #[error("bridge credit is not final under the supplied receipt: {0}")] + BridgeCreditNotFinal(String), + #[error("bridge spend reference is invalid: {0}")] + BridgeSpendReferenceInvalid(String), +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum ConsensusValidationError { + #[error("wrong chain id: expected {expected}, got {actual}")] + WrongChainId { expected: String, actual: String }, + #[error("wrong genesis hash: expected {expected}, got {actual}")] + WrongGenesisHash { expected: String, actual: String }, + #[error("invalid parent: expected {expected}, got {actual}")] + InvalidParent { expected: String, actual: String }, + #[error("invalid height: expected {expected}, got {actual}")] + InvalidHeight { expected: u64, actual: u64 }, + #[error("timestamp out of bounds: min {min}, max {max}, got {actual}")] + TimestampOutOfBounds { min: u64, max: u64, actual: u64 }, + #[error("invalid proposer: {0}")] + InvalidProposer(String), + #[error("invalid authority proof: {0}")] + InvalidAuthorityProof(String), + #[error("transaction ids are not sorted deterministically")] + TransactionOrdering, + #[error("duplicate transaction id in block: {0}")] + DuplicateTransaction(String), + #[error("{root_name} mismatch: expected {expected}, got {actual}")] + RootMismatch { + root_name: String, + expected: String, + actual: String, + }, + #[error("block hash mismatch: expected {expected}, got {actual}")] + BlockHashMismatch { expected: String, actual: String }, + #[error("unknown parent: {0}")] + UnknownParent(String), + #[error("stale block height {0}")] + StaleBlock(u64), + #[error("block conflicts with finalized height {height} hash {finalized_hash}")] + FinalizedConflict { height: u64, finalized_hash: String }, + #[error("missing finality receipt for block {block_height} hash {block_hash}")] + MissingFinalityReceipt { + block_height: u64, + block_hash: String, + }, + #[error("duplicate finality receipt for block {block_height} hash {block_hash}")] + DuplicateFinalityReceipt { + block_height: u64, + block_hash: String, + }, + #[error("finality receipt mismatch: {0}")] + FinalityReceiptMismatch(String), + #[error("bridge credit does not exist: {0}")] + BridgeCreditMissing(String), + #[error("bridge spend reference is invalid: {0}")] + BridgeSpendReferenceInvalid(String), } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -153,6 +235,18 @@ pub struct ChainState { pub parent_hash: String, #[serde(default = "default_operator_key_references")] pub operator_key_references: BTreeMap, + #[serde(default = "default_validator_set")] + pub validator_set: BTreeMap, + #[serde(default = "default_authority_set")] + pub authority_set: AuthoritySet, + #[serde(default = "default_consensus_state")] + pub consensus_state: ConsensusState, + #[serde(default)] + pub chain_finality_receipts: BTreeMap, + #[serde(default)] + pub fork_evidence: Vec, + #[serde(default)] + pub misbehavior_evidence: Vec, pub rootfields: BTreeMap, #[serde(default)] pub agent_accounts: BTreeMap, @@ -194,6 +288,10 @@ pub struct ChainState { pub imported_observations: BTreeMap, pub imported_verifier_reports: BTreeMap, pub base_anchors: BTreeMap, + #[serde(default)] + pub bridge_replay_keys: BTreeMap, + #[serde(default)] + pub bridge_credits: BTreeMap, pub blocks: Vec, pub pending_txs: Vec, } @@ -228,6 +326,126 @@ pub struct OperatorKeyReference { pub crypto_schema_refs: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ValidatorIdentity { + pub schema: String, + pub validator_id: String, + pub account_ref: String, + pub consensus_public_key: String, + pub consensus_key_id: String, + pub roles: Vec, + pub weight: u64, + pub active: bool, + pub key_scope: String, + pub bridge_key_separation: String, + pub wallet_key_separation: String, + pub public_metadata: ValidatorPublicMetadata, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ValidatorPublicMetadata { + pub display_name: String, + pub operator_ref: String, + pub network_profile: String, + pub dashboard_safe: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AuthoritySet { + pub schema: String, + pub authority_set_id: String, + pub chain_id: String, + pub genesis_hash: String, + pub profile: String, + pub validators: Vec, + pub proposer_schedule: Vec, + pub total_weight: u64, + pub quorum_weight: u64, + pub public_metadata_export: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConsensusState { + pub schema: String, + pub profile: String, + pub finality_rule: String, + pub canonical_height: u64, + pub canonical_head_hash: String, + pub canonical_state_root: String, + pub finalized_height: u64, + pub finalized_hash: String, + pub finalized_state_root: String, + pub finalized_at_logical_time: u64, + pub latest_finality_receipt_id: Option, + pub consensus_state_output_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct FinalityCertificate { + pub schema: String, + pub certificate_id: String, + pub chain_id: String, + pub genesis_hash: String, + pub authority_set_id: String, + pub block_height: u64, + pub block_hash: String, + pub state_root: String, + pub signer_ids: Vec, + pub quorum_weight: u64, + pub total_weight: u64, + pub profile: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConsensusFinalityReceipt { + pub schema: String, + pub finality_receipt_id: String, + pub chain_id: String, + pub genesis_hash: String, + pub authority_set_id: String, + pub finalized_height: u64, + pub finalized_block_hash: String, + pub finalized_state_root: String, + pub canonical_head_hash: String, + pub canonical_height: u64, + pub finality_rule: String, + pub certificate: FinalityCertificate, + pub produced_at_logical_time: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct MisbehaviorEvidence { + pub schema: String, + pub evidence_id: String, + pub kind: String, + pub block_hash: String, + pub block_height: u64, + pub proposer_id: String, + pub detail: String, + pub observed_at_logical_time: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ForkEvidence { + pub schema: String, + pub evidence_id: String, + pub kind: String, + pub block_hash: String, + pub block_height: u64, + pub parent_hash: String, + pub reason: String, + pub canonical_head_hash: String, + pub observed_at_logical_time: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Rootfield { @@ -581,6 +799,85 @@ pub struct BaseAnchorPlaceholder { pub finality_status: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BridgeReplayKeyRecord { + pub schema: String, + pub replay_key: String, + pub source_chain_id: String, + pub source_tx_hash: String, + pub source_log_index: String, + pub local_object_id: String, + pub status: String, + pub first_seen_block: u64, + pub finalized_at_height: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BridgeCreditRecord { + pub schema: String, + pub credit_id: String, + pub replay_key: String, + pub source_chain_id: String, + pub source_tx_hash: String, + pub source_log_index: String, + pub recipient_account_id: String, + pub amount_units: u64, + pub evidence_hash: String, + pub credited_at_block: u64, + pub status: String, + pub finality_required: bool, + pub local_only: bool, + pub production_ready: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BridgeLifecycleEvidence { + pub schema: String, + pub evidence_id: String, + pub credit_id: String, + pub credit_tx_id: String, + pub credit_included_in_block: u64, + pub credit_block_hash: String, + pub credit_receipt_status: String, + pub block_hash_includes_credit_tx_and_receipt: bool, + pub state_root_before_credit: String, + pub state_root_after_credit: String, + pub state_root_changed_after_credit: bool, + pub finality: BridgeCreditFinalityEvidence, + pub transfer_after_credit: BridgeSpendEvidence, + pub result: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BridgeCreditFinalityEvidence { + pub required: bool, + pub status: String, + pub finality_receipt_id: Option, + pub finalized_height: u64, + pub finalized_block_hash: String, + pub finalized_state_root: String, + pub rule: String, + pub private_pilot: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BridgeSpendEvidence { + pub transfer_id: String, + pub transfer_tx_id: String, + pub transfer_block_number: u64, + pub transfer_block_hash: String, + pub transfer_receipt_status: String, + pub references_credit_id: bool, + pub references_finality_receipt_id: bool, + pub references_credited_state_root: bool, + pub spendable_under_finality_rule: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde( tag = "type", @@ -745,6 +1042,34 @@ pub enum Transaction { appchain_chain_id: String, finality_status: String, }, + RecordBridgeReplayKey { + replay_key: String, + source_chain_id: String, + source_tx_hash: String, + source_log_index: String, + local_object_id: String, + }, + ApplyBridgeCredit { + credit_id: String, + replay_key: String, + source_chain_id: String, + source_tx_hash: String, + source_log_index: String, + recipient_account_id: String, + amount_units: u64, + evidence_hash: String, + }, + SpendBridgeCreditLocalTestUnits { + transfer_id: String, + credit_id: String, + finality_receipt_id: String, + credited_block_hash: String, + credited_state_root: String, + from_account_id: String, + to_account_id: String, + amount_units: u64, + memo: String, + }, ImportFlowPulseObservation(ImportedFlowPulseObservation), ImportVerifierReport(ImportedVerifierReport), } @@ -766,16 +1091,43 @@ pub struct LocalAuthorization { pub digest: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AuthorityProof { + pub schema: String, + pub proof_type: String, + pub validator_id: String, + pub consensus_key_id: String, + pub digest: String, + pub signature: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Block { pub schema: String, + #[serde(default = "default_chain_id")] + pub chain_id: String, + #[serde(default = "default_genesis_hash")] + pub genesis_hash: String, + #[serde(default = "default_authority_set_id")] + pub authority_set_id: String, + #[serde(default = "default_validator_id")] + pub proposer_id: String, pub block_number: u64, pub parent_hash: String, pub logical_time: u64, pub tx_ids: Vec, + #[serde(default = "empty_root")] + pub tx_root: String, pub receipts: Vec, + #[serde(default = "empty_root")] + pub receipt_root: String, + #[serde(default = "empty_root")] + pub event_root: String, pub state_root: String, + #[serde(default = "empty_authority_proof")] + pub authority_proof: AuthorityProof, pub block_hash: String, } @@ -797,6 +1149,8 @@ struct StateCommitmentView<'a> { chain_id: &'a str, genesis_hash: &'a str, operator_key_references: &'a BTreeMap, + validator_set: &'a BTreeMap, + authority_set: &'a AuthoritySet, rootfields: &'a BTreeMap, agent_accounts: &'a BTreeMap, local_test_unit_balances: &'a BTreeMap, @@ -821,6 +1175,8 @@ struct StateCommitmentView<'a> { imported_observations: &'a BTreeMap, imported_verifier_reports: &'a BTreeMap, base_anchors: &'a BTreeMap, + bridge_replay_keys: &'a BTreeMap, + bridge_credits: &'a BTreeMap, } #[derive(Debug, Serialize)] @@ -834,6 +1190,8 @@ struct RootMapView<'a, T> { #[serde(rename_all = "camelCase")] pub struct StateMapRoots { pub operator_key_reference_root: String, + pub validator_set_root: String, + pub authority_set_root: String, pub rootfield_state_root: String, pub agent_account_root: String, pub local_test_unit_balance_root: String, @@ -858,6 +1216,8 @@ pub struct StateMapRoots { pub imported_observation_root: String, pub imported_verifier_report_root: String, pub base_anchor_root: String, + pub bridge_replay_key_root: String, + pub bridge_credit_root: String, } pub fn default_config() -> DevnetConfig { @@ -870,7 +1230,9 @@ pub fn default_config() -> DevnetConfig { 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(), + consensus: + "private/local authority-set consensus with deterministic proposer schedule and immediate local finality" + .to_string(), crypto_schema_refs: vec![ "crypto/FLOWMEMORY_CRYPTO_SPEC.md#domain-separation".to_string(), "crypto/ATTESTATIONS.md#local-signature-helpers".to_string(), @@ -878,6 +1240,37 @@ pub fn default_config() -> DevnetConfig { } } +fn default_chain_id() -> String { + default_config().chain_id +} + +fn default_genesis_hash() -> String { + GENESIS_HASH.to_string() +} + +fn default_authority_set_id() -> String { + LOCAL_PRIVATE_AUTHORITY_SET_ID.to_string() +} + +fn default_validator_id() -> String { + LOCAL_PRIVATE_VALIDATOR_ID.to_string() +} + +fn empty_root() -> String { + ZERO_HASH.to_string() +} + +fn empty_authority_proof() -> AuthorityProof { + AuthorityProof { + schema: AUTHORITY_PROOF_SCHEMA.to_string(), + proof_type: "unset".to_string(), + validator_id: String::new(), + consensus_key_id: String::new(), + digest: ZERO_HASH.to_string(), + signature: ZERO_HASH.to_string(), + } +} + pub fn default_operator_key_references() -> BTreeMap { let reference = OperatorKeyReference { schema: OPERATOR_KEY_REFERENCE_SCHEMA.to_string(), @@ -899,9 +1292,74 @@ pub fn default_operator_key_references() -> BTreeMap BTreeMap { + let validator = ValidatorIdentity { + schema: VALIDATOR_IDENTITY_SCHEMA.to_string(), + validator_id: LOCAL_PRIVATE_VALIDATOR_ID.to_string(), + account_ref: "operator:local-private:alpha".to_string(), + consensus_public_key: keccak_hex(b"flowmemory.local_private.validator.alpha.public"), + consensus_key_id: "consensus-key:local-private:alpha".to_string(), + roles: vec![ + "validator".to_string(), + "sequencer".to_string(), + "proposer".to_string(), + "finality-signer".to_string(), + ], + weight: 1, + active: true, + key_scope: "consensus-only-local-private-authority".to_string(), + bridge_key_separation: "consensus keys do not sign bridge release or custody instructions" + .to_string(), + wallet_key_separation: "consensus keys are separate from local user wallet and faucet keys" + .to_string(), + public_metadata: ValidatorPublicMetadata { + display_name: "FlowMemory local private authority alpha".to_string(), + operator_ref: "operator:local-private:alpha".to_string(), + network_profile: "private-local-single-authority".to_string(), + dashboard_safe: true, + }, + }; + BTreeMap::from([(validator.validator_id.clone(), validator)]) +} + +pub fn default_authority_set() -> AuthoritySet { + AuthoritySet { + schema: AUTHORITY_SET_SCHEMA.to_string(), + authority_set_id: LOCAL_PRIVATE_AUTHORITY_SET_ID.to_string(), + chain_id: default_chain_id(), + genesis_hash: GENESIS_HASH.to_string(), + profile: "private-local-authority-set".to_string(), + validators: vec![LOCAL_PRIVATE_VALIDATOR_ID.to_string()], + proposer_schedule: vec![LOCAL_PRIVATE_VALIDATOR_ID.to_string()], + total_weight: 1, + quorum_weight: 1, + public_metadata_export: "dashboard-safe validator metadata only; no secret key material" + .to_string(), + } +} + +pub fn default_consensus_state() -> ConsensusState { + ConsensusState { + schema: CONSENSUS_STATE_SCHEMA.to_string(), + profile: "private-local-single-authority".to_string(), + finality_rule: + "single local authority finalizes the canonical block immediately after validation" + .to_string(), + canonical_height: 0, + canonical_head_hash: GENESIS_HASH.to_string(), + canonical_state_root: ZERO_HASH.to_string(), + finalized_height: 0, + finalized_hash: GENESIS_HASH.to_string(), + finalized_state_root: ZERO_HASH.to_string(), + finalized_at_logical_time: default_config().genesis_logical_time, + latest_finality_receipt_id: None, + consensus_state_output_path: "devnet/local/consensus-state.json".to_string(), + } +} + pub fn genesis_state() -> ChainState { let config = default_config(); - ChainState { + let mut state = ChainState { schema: STATE_SCHEMA.to_string(), chain_id: config.chain_id.clone(), genesis_hash: config.genesis_hash.clone(), @@ -910,6 +1368,12 @@ pub fn genesis_state() -> ChainState { parent_hash: config.genesis_hash.clone(), config, operator_key_references: default_operator_key_references(), + validator_set: default_validator_set(), + authority_set: default_authority_set(), + consensus_state: default_consensus_state(), + chain_finality_receipts: BTreeMap::new(), + fork_evidence: Vec::new(), + misbehavior_evidence: Vec::new(), rootfields: BTreeMap::new(), agent_accounts: BTreeMap::new(), local_test_unit_balances: BTreeMap::new(), @@ -934,9 +1398,15 @@ pub fn genesis_state() -> ChainState { imported_observations: BTreeMap::new(), imported_verifier_reports: BTreeMap::new(), base_anchors: BTreeMap::new(), + bridge_replay_keys: BTreeMap::new(), + bridge_credits: BTreeMap::new(), blocks: Vec::new(), pending_txs: Vec::new(), - } + }; + let genesis_root = state_root(&state); + state.consensus_state.canonical_state_root = genesis_root.clone(); + state.consensus_state.finalized_state_root = genesis_root; + state } pub fn envelope_tx(tx: Transaction) -> TxEnvelope { @@ -1074,6 +1544,8 @@ pub fn state_root(state: &ChainState) -> String { chain_id: &state.chain_id, genesis_hash: &state.genesis_hash, operator_key_references: &state.operator_key_references, + validator_set: &state.validator_set, + authority_set: &state.authority_set, rootfields: &state.rootfields, agent_accounts: &state.agent_accounts, local_test_unit_balances: &state.local_test_unit_balances, @@ -1098,6 +1570,8 @@ pub fn state_root(state: &ChainState) -> String { imported_observations: &state.imported_observations, imported_verifier_reports: &state.imported_verifier_reports, base_anchors: &state.base_anchors, + bridge_replay_keys: &state.bridge_replay_keys, + bridge_credits: &state.bridge_credits, }; hash_json("flowmemory.local_devnet.state_root.v0", &view) } @@ -1115,6 +1589,14 @@ pub fn state_map_roots(state: &ChainState) -> StateMapRoots { "flowmemory.local_devnet.operator_key_references.v0", &state.operator_key_references, ), + validator_set_root: map_root( + "flowmemory.local_devnet.validator_set.v0", + &state.validator_set, + ), + authority_set_root: hash_json( + "flowmemory.local_devnet.authority_set_root.v0", + &state.authority_set, + ), rootfield_state_root: map_root("flowmemory.local_devnet.rootfields.v0", &state.rootfields), agent_account_root: map_root( "flowmemory.local_devnet.agent_accounts.v0", @@ -1202,20 +1684,279 @@ pub fn state_map_roots(state: &ChainState) -> StateMapRoots { "flowmemory.local_devnet.base_anchors.v0", &state.base_anchors, ), + bridge_replay_key_root: map_root( + "flowmemory.local_devnet.bridge_replay_keys.v0", + &state.bridge_replay_keys, + ), + bridge_credit_root: map_root( + "flowmemory.local_devnet.bridge_credits.v0", + &state.bridge_credits, + ), } } -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()); - let mut tx_ids = Vec::with_capacity(txs.len()); +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BlockProposal { + pub block: Block, + pub transactions: Vec, + pub post_state: ChainState, +} - for envelope in txs { - tx_ids.push(envelope.tx_id.clone()); +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ForkChoiceOutcome { + pub schema: String, + pub canonical_head: Option, + pub rejected: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConsensusValidationReport { + pub schema: String, + pub valid: bool, + pub checked_blocks: usize, + pub canonical_head_hash: String, + pub finalized_height: u64, + pub finalized_hash: String, + pub finalized_state_root: String, + pub consensus_state_root: String, + pub errors: Vec, +} + +pub fn tx_root(tx_ids: &[String]) -> String { + hash_json( + "flowmemory.local_devnet.tx_root.v0", + &serde_json::json!({ + "schema": "flowmemory.local_devnet.tx_root.v0", + "txIds": tx_ids + }), + ) +} + +pub fn receipt_root(receipts: &[BlockReceipt]) -> String { + hash_json( + "flowmemory.local_devnet.receipt_root.v0", + &serde_json::json!({ + "schema": "flowmemory.local_devnet.receipt_root.v0", + "receipts": receipts + }), + ) +} + +pub fn event_root(receipts: &[BlockReceipt]) -> String { + let events = receipts + .iter() + .map(|receipt| { + serde_json::json!({ + "schema": "flowmemory.local_devnet.block_event_commitment.v0", + "txId": receipt.tx_id, + "eventType": if receipt.status == "applied" { + "transaction_applied" + } else { + "transaction_rejected" + }, + "status": receipt.status + }) + }) + .collect::>(); + hash_json( + "flowmemory.local_devnet.event_root.v0", + &serde_json::json!({ + "schema": "flowmemory.local_devnet.event_root.v0", + "events": events + }), + ) +} + +pub fn consensus_state_root(state: &ChainState) -> String { + hash_json( + "flowmemory.local_devnet.consensus_state_root.v0", + &serde_json::json!({ + "schema": "flowmemory.local_devnet.consensus_state_commitment.v0", + "authoritySet": state.authority_set, + "validatorSet": state.validator_set, + "consensusState": state.consensus_state, + "chainFinalityReceipts": state.chain_finality_receipts, + "forkEvidence": state.fork_evidence, + "misbehaviorEvidence": state.misbehavior_evidence + }), + ) +} + +pub fn expected_proposer_id(state: &ChainState, height: u64) -> Option { + if state.authority_set.proposer_schedule.is_empty() || height == 0 { + return None; + } + let index = ((height - 1) as usize) % state.authority_set.proposer_schedule.len(); + state.authority_set.proposer_schedule.get(index).cloned() +} + +pub fn validator_has_role(state: &ChainState, validator_id: &str, role: &str) -> bool { + state + .validator_set + .get(validator_id) + .is_some_and(|validator| { + validator.active + && validator + .roles + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(role)) + }) +} + +pub fn calculate_block_hash(block: &Block) -> String { + let mut input = block.clone(); + input.block_hash = ZERO_HASH.to_string(); + hash_json("flowmemory.local_devnet.block_hash.v0", &input) +} + +fn authority_digest(block: &Block) -> String { + let mut input = block.clone(); + input.block_hash = ZERO_HASH.to_string(); + input.authority_proof = empty_authority_proof(); + hash_json("flowmemory.local_devnet.authority_digest.v0", &input) +} + +fn authority_signature( + validator_id: &str, + consensus_key_id: &str, + consensus_public_key: &str, + digest: &str, +) -> String { + hash_json( + "flowmemory.local_devnet.authority_signature.v0", + &serde_json::json!({ + "validatorId": validator_id, + "consensusKeyId": consensus_key_id, + "consensusPublicKey": consensus_public_key, + "digest": digest + }), + ) +} + +fn authority_proof_for_block( + state: &ChainState, + block: &Block, + proposer_id: &str, +) -> Result { + let validator = state + .validator_set + .get(proposer_id) + .filter(|validator| validator.active) + .ok_or_else(|| ConsensusValidationError::InvalidProposer(proposer_id.to_string()))?; + if !validator_has_role(state, proposer_id, "proposer") { + return Err(ConsensusValidationError::InvalidProposer( + proposer_id.to_string(), + )); + } + let digest = authority_digest(block); + Ok(AuthorityProof { + schema: AUTHORITY_PROOF_SCHEMA.to_string(), + proof_type: "local-private-authority-digest".to_string(), + validator_id: proposer_id.to_string(), + consensus_key_id: validator.consensus_key_id.clone(), + signature: authority_signature( + proposer_id, + &validator.consensus_key_id, + &validator.consensus_public_key, + &digest, + ), + digest, + }) +} + +fn validate_authority_proof( + state: &ChainState, + block: &Block, +) -> Result<(), ConsensusValidationError> { + if block.authority_proof.validator_id != block.proposer_id { + return Err(ConsensusValidationError::InvalidAuthorityProof( + "authority proof validator does not match proposer".to_string(), + )); + } + let validator = state + .validator_set + .get(&block.proposer_id) + .filter(|validator| validator.active) + .ok_or_else(|| ConsensusValidationError::InvalidProposer(block.proposer_id.clone()))?; + let expected_digest = authority_digest(block); + if block.authority_proof.digest != expected_digest { + return Err(ConsensusValidationError::InvalidAuthorityProof( + "authority proof digest mismatch".to_string(), + )); + } + let expected_signature = authority_signature( + &block.proposer_id, + &validator.consensus_key_id, + &validator.consensus_public_key, + &expected_digest, + ); + if block.authority_proof.consensus_key_id != validator.consensus_key_id + || block.authority_proof.signature != expected_signature + { + return Err(ConsensusValidationError::InvalidAuthorityProof( + "authority proof signature mismatch".to_string(), + )); + } + Ok(()) +} + +fn ensure_unique_tx_ids(tx_ids: &[String]) -> Result<(), ConsensusValidationError> { + let mut seen = BTreeSet::new(); + for tx_id in tx_ids { + if !seen.insert(tx_id) { + return Err(ConsensusValidationError::DuplicateTransaction( + tx_id.clone(), + )); + } + } + Ok(()) +} + +pub fn propose_block( + parent_state: &ChainState, + transactions: Vec, + proposer_id: &str, +) -> Result { + if !validator_has_role(parent_state, proposer_id, "proposer") { + return Err(ConsensusValidationError::InvalidProposer( + proposer_id.to_string(), + )); + } + let expected_proposer = expected_proposer_id(parent_state, parent_state.next_block_number) + .ok_or_else(|| { + ConsensusValidationError::InvalidProposer("empty proposer schedule".to_string()) + })?; + if expected_proposer != proposer_id { + return Err(ConsensusValidationError::InvalidProposer( + proposer_id.to_string(), + )); + } + + let mut post_state = parent_state.clone(); + post_state.pending_txs.clear(); + + let mut receipts = Vec::with_capacity(transactions.len()); + let mut tx_ids = Vec::with_capacity(transactions.len()); + let mut seen = BTreeSet::new(); + + for envelope in &transactions { let authorization = envelope.authorization.clone(); - let result = apply_transaction(state, &envelope.tx); + if !seen.insert(envelope.tx_id.clone()) { + receipts.push(BlockReceipt { + tx_id: envelope.tx_id.clone(), + status: "rejected".to_string(), + error: Some(DevnetError::DuplicateTransaction(envelope.tx_id.clone()).to_string()), + authorization, + }); + continue; + } + tx_ids.push(envelope.tx_id.clone()); + let result = apply_transaction(&mut post_state, &envelope.tx); receipts.push(BlockReceipt { - tx_id: envelope.tx_id, + tx_id: envelope.tx_id.clone(), status: if result.is_ok() { "applied" } else { @@ -1227,34 +1968,964 @@ pub fn build_block(state: &mut ChainState) -> Block { }); } - let root = state_root(state); - let block_number = state.next_block_number; - let logical_time = state.logical_time; - let parent_hash = state.parent_hash.clone(); - let mut block = Block { schema: BLOCK_SCHEMA.to_string(), - block_number, - parent_hash, - logical_time, + chain_id: parent_state.chain_id.clone(), + genesis_hash: parent_state.genesis_hash.clone(), + authority_set_id: parent_state.authority_set.authority_set_id.clone(), + proposer_id: proposer_id.to_string(), + block_number: parent_state.next_block_number, + parent_hash: parent_state.parent_hash.clone(), + logical_time: parent_state.logical_time, + tx_root: tx_root(&tx_ids), tx_ids, + receipt_root: receipt_root(&receipts), + event_root: event_root(&receipts), receipts, - state_root: root, + state_root: state_root(&post_state), + authority_proof: empty_authority_proof(), block_hash: ZERO_HASH.to_string(), }; - block.block_hash = hash_json("flowmemory.local_devnet.block_hash.v0", &block); + block.authority_proof = authority_proof_for_block(parent_state, &block, proposer_id)?; + block.block_hash = calculate_block_hash(&block); - state.next_block_number += 1; - state.logical_time += 1; - state.parent_hash = block.block_hash.clone(); - state.blocks.push(block.clone()); + let proposal = BlockProposal { + block, + transactions, + post_state, + }; + validate_block_header(parent_state, &proposal.block)?; + Ok(proposal) +} - block +pub fn validate_block_proposal( + parent_state: &ChainState, + proposal: &BlockProposal, +) -> Result<(), ConsensusValidationError> { + let expected = propose_block( + parent_state, + proposal.transactions.clone(), + &proposal.block.proposer_id, + )?; + if expected.block.state_root != proposal.block.state_root { + return Err(ConsensusValidationError::RootMismatch { + root_name: "stateRoot".to_string(), + expected: expected.block.state_root, + actual: proposal.block.state_root.clone(), + }); + } + if expected.block.tx_root != proposal.block.tx_root { + return Err(ConsensusValidationError::RootMismatch { + root_name: "txRoot".to_string(), + expected: expected.block.tx_root, + actual: proposal.block.tx_root.clone(), + }); + } + if expected.block.receipt_root != proposal.block.receipt_root { + return Err(ConsensusValidationError::RootMismatch { + root_name: "receiptRoot".to_string(), + expected: expected.block.receipt_root, + actual: proposal.block.receipt_root.clone(), + }); + } + if expected.block.event_root != proposal.block.event_root { + return Err(ConsensusValidationError::RootMismatch { + root_name: "eventRoot".to_string(), + expected: expected.block.event_root, + actual: proposal.block.event_root.clone(), + }); + } + if expected.block.block_hash != proposal.block.block_hash { + return Err(ConsensusValidationError::BlockHashMismatch { + expected: expected.block.block_hash, + actual: proposal.block.block_hash.clone(), + }); + } + validate_block_header(parent_state, &proposal.block)?; + Ok(()) } -pub fn apply_transaction(state: &mut ChainState, tx: &Transaction) -> Result<(), DevnetError> { - match tx { - Transaction::RegisterRootfield { +pub fn validate_block_header( + parent_state: &ChainState, + block: &Block, +) -> Result<(), ConsensusValidationError> { + if block.chain_id != parent_state.chain_id { + return Err(ConsensusValidationError::WrongChainId { + expected: parent_state.chain_id.clone(), + actual: block.chain_id.clone(), + }); + } + if block.genesis_hash != parent_state.genesis_hash { + return Err(ConsensusValidationError::WrongGenesisHash { + expected: parent_state.genesis_hash.clone(), + actual: block.genesis_hash.clone(), + }); + } + if block.authority_set_id != parent_state.authority_set.authority_set_id { + return Err(ConsensusValidationError::InvalidAuthorityProof( + "authority set id mismatch".to_string(), + )); + } + if block.parent_hash != parent_state.parent_hash { + return Err(ConsensusValidationError::InvalidParent { + expected: parent_state.parent_hash.clone(), + actual: block.parent_hash.clone(), + }); + } + if block.block_number != parent_state.next_block_number { + return Err(ConsensusValidationError::InvalidHeight { + expected: parent_state.next_block_number, + actual: block.block_number, + }); + } + let min_time = parent_state.logical_time; + let max_time = parent_state + .logical_time + .saturating_add(parent_state.config.block_time_seconds.max(1) * 2); + if block.logical_time < min_time || block.logical_time > max_time { + return Err(ConsensusValidationError::TimestampOutOfBounds { + min: min_time, + max: max_time, + actual: block.logical_time, + }); + } + let expected_proposer = + expected_proposer_id(parent_state, block.block_number).ok_or_else(|| { + ConsensusValidationError::InvalidProposer("empty proposer schedule".to_string()) + })?; + if block.proposer_id != expected_proposer + || !validator_has_role(parent_state, &block.proposer_id, "proposer") + { + return Err(ConsensusValidationError::InvalidProposer( + block.proposer_id.clone(), + )); + } + if block.block_number <= parent_state.consensus_state.finalized_height { + let finalized_hash = parent_state + .blocks + .iter() + .find(|candidate| candidate.block_number == block.block_number) + .map(|candidate| candidate.block_hash.clone()) + .unwrap_or_else(|| parent_state.consensus_state.finalized_hash.clone()); + if block.block_hash != finalized_hash { + return Err(ConsensusValidationError::FinalizedConflict { + height: parent_state.consensus_state.finalized_height, + finalized_hash, + }); + } + } + ensure_unique_tx_ids(&block.tx_ids)?; + let expected_tx_root = tx_root(&block.tx_ids); + if block.tx_root != expected_tx_root { + return Err(ConsensusValidationError::RootMismatch { + root_name: "txRoot".to_string(), + expected: expected_tx_root, + actual: block.tx_root.clone(), + }); + } + let expected_receipt_root = receipt_root(&block.receipts); + if block.receipt_root != expected_receipt_root { + return Err(ConsensusValidationError::RootMismatch { + root_name: "receiptRoot".to_string(), + expected: expected_receipt_root, + actual: block.receipt_root.clone(), + }); + } + let expected_event_root = event_root(&block.receipts); + if block.event_root != expected_event_root { + return Err(ConsensusValidationError::RootMismatch { + root_name: "eventRoot".to_string(), + expected: expected_event_root, + actual: block.event_root.clone(), + }); + } + validate_authority_proof(parent_state, block)?; + let expected_hash = calculate_block_hash(block); + if block.block_hash != expected_hash { + return Err(ConsensusValidationError::BlockHashMismatch { + expected: expected_hash, + actual: block.block_hash.clone(), + }); + } + Ok(()) +} + +pub fn build_block(state: &mut ChainState) -> Block { + let proposer = expected_proposer_id(state, state.next_block_number) + .unwrap_or_else(|| LOCAL_PRIVATE_VALIDATOR_ID.to_string()); + build_block_with_proposer(state, &proposer) + .expect("default local-private proposer should be valid") +} + +pub fn build_block_with_proposer( + state: &mut ChainState, + proposer_id: &str, +) -> Result { + let proposal = propose_block(state, state.pending_txs.clone(), proposer_id)?; + commit_block_proposal(state, proposal) +} + +pub fn commit_block_proposal( + state: &mut ChainState, + proposal: BlockProposal, +) -> Result { + validate_block_proposal(state, &proposal)?; + let block = proposal.block; + let mut next_state = proposal.post_state; + next_state.next_block_number = block.block_number + 1; + next_state.logical_time = block.logical_time + 1; + next_state.parent_hash = block.block_hash.clone(); + next_state.blocks.push(block.clone()); + update_consensus_after_block(&mut next_state, &block); + *state = next_state; + Ok(block) +} + +fn update_consensus_after_block(state: &mut ChainState, block: &Block) { + state.consensus_state.canonical_height = block.block_number; + state.consensus_state.canonical_head_hash = block.block_hash.clone(); + state.consensus_state.canonical_state_root = block.state_root.clone(); + let receipt = chain_finality_receipt_for_block(state, block); + state.consensus_state.finalized_height = receipt.finalized_height; + state.consensus_state.finalized_hash = receipt.finalized_block_hash.clone(); + state.consensus_state.finalized_state_root = receipt.finalized_state_root.clone(); + state.consensus_state.finalized_at_logical_time = receipt.produced_at_logical_time; + state.consensus_state.latest_finality_receipt_id = Some(receipt.finality_receipt_id.clone()); + state + .chain_finality_receipts + .insert(receipt.finality_receipt_id.clone(), receipt); +} + +fn chain_finality_receipt_for_block(state: &ChainState, block: &Block) -> ConsensusFinalityReceipt { + let signer_ids = vec![block.proposer_id.clone()]; + let certificate_id = hash_json( + "flowmemory.local_devnet.finality_certificate_id.v0", + &serde_json::json!({ + "chainId": block.chain_id, + "genesisHash": block.genesis_hash, + "authoritySetId": block.authority_set_id, + "blockHeight": block.block_number, + "blockHash": block.block_hash, + "stateRoot": block.state_root, + "signerIds": signer_ids + }), + ); + let certificate = FinalityCertificate { + schema: FINALITY_CERTIFICATE_SCHEMA.to_string(), + certificate_id, + chain_id: block.chain_id.clone(), + genesis_hash: block.genesis_hash.clone(), + authority_set_id: block.authority_set_id.clone(), + block_height: block.block_number, + block_hash: block.block_hash.clone(), + state_root: block.state_root.clone(), + signer_ids, + quorum_weight: state.authority_set.quorum_weight, + total_weight: state.authority_set.total_weight, + profile: state.consensus_state.profile.clone(), + }; + let finality_receipt_id = hash_json( + "flowmemory.local_devnet.chain_finality_receipt_id.v0", + &serde_json::json!({ + "certificateId": certificate.certificate_id, + "blockHash": block.block_hash, + "blockHeight": block.block_number, + "stateRoot": block.state_root + }), + ); + ConsensusFinalityReceipt { + schema: CHAIN_FINALITY_RECEIPT_SCHEMA.to_string(), + finality_receipt_id, + chain_id: block.chain_id.clone(), + genesis_hash: block.genesis_hash.clone(), + authority_set_id: block.authority_set_id.clone(), + finalized_height: block.block_number, + finalized_block_hash: block.block_hash.clone(), + finalized_state_root: block.state_root.clone(), + canonical_head_hash: block.block_hash.clone(), + canonical_height: block.block_number, + finality_rule: state.consensus_state.finality_rule.clone(), + certificate, + produced_at_logical_time: block.logical_time, + } +} + +pub fn finalized_height(state: &ChainState) -> u64 { + state.consensus_state.finalized_height +} + +pub fn finalized_state_root(state: &ChainState) -> String { + state.consensus_state.finalized_state_root.clone() +} + +pub fn finalized_hash(state: &ChainState) -> String { + state.consensus_state.finalized_hash.clone() +} + +pub fn bridge_replay_key_is_final(state: &ChainState, replay_key: &str) -> bool { + state + .bridge_replay_keys + .get(replay_key) + .is_some_and(|record| record.first_seen_block <= state.consensus_state.finalized_height) +} + +pub fn bridge_credit_transaction_id(credit: &BridgeCreditRecord) -> String { + hash_json( + TX_SCHEMA, + &Transaction::ApplyBridgeCredit { + credit_id: credit.credit_id.clone(), + replay_key: credit.replay_key.clone(), + source_chain_id: credit.source_chain_id.clone(), + source_tx_hash: credit.source_tx_hash.clone(), + source_log_index: credit.source_log_index.clone(), + recipient_account_id: credit.recipient_account_id.clone(), + amount_units: credit.amount_units, + evidence_hash: credit.evidence_hash.clone(), + }, + ) +} + +fn bridge_spend_transaction_id( + transfer: &BalanceTransfer, + credit_id: &str, + finality_receipt_id: &str, + credited_block_hash: &str, + credited_state_root: &str, +) -> String { + hash_json( + TX_SCHEMA, + &Transaction::SpendBridgeCreditLocalTestUnits { + transfer_id: transfer.transfer_id.clone(), + credit_id: credit_id.to_string(), + finality_receipt_id: finality_receipt_id.to_string(), + credited_block_hash: credited_block_hash.to_string(), + credited_state_root: credited_state_root.to_string(), + from_account_id: transfer.from_account_id.clone(), + to_account_id: transfer.to_account_id.clone(), + amount_units: transfer.amount_units, + memo: transfer.memo.clone(), + }, + ) +} + +fn block_receipt_status(block: &Block, tx_id: &str) -> Option { + block + .receipts + .iter() + .find(|receipt| receipt.tx_id == tx_id) + .map(|receipt| receipt.status.clone()) +} + +pub fn finality_receipt_for_block<'a>( + state: &'a ChainState, + block: &Block, +) -> Result<&'a ConsensusFinalityReceipt, ConsensusValidationError> { + let receipts = state + .chain_finality_receipts + .values() + .filter(|receipt| { + receipt.finalized_height == block.block_number + && receipt.finalized_block_hash == block.block_hash + }) + .collect::>(); + match receipts.len() { + 0 => Err(ConsensusValidationError::MissingFinalityReceipt { + block_height: block.block_number, + block_hash: block.block_hash.clone(), + }), + 1 => { + let receipt = receipts[0]; + if receipt.finalized_state_root != block.state_root { + return Err(ConsensusValidationError::FinalityReceiptMismatch( + "finalized state root does not match credited block".to_string(), + )); + } + if receipt.certificate.block_hash != block.block_hash + || receipt.certificate.state_root != block.state_root + || receipt.certificate.block_height != block.block_number + { + return Err(ConsensusValidationError::FinalityReceiptMismatch( + "certificate does not cover credited block".to_string(), + )); + } + Ok(receipt) + } + _ => Err(ConsensusValidationError::DuplicateFinalityReceipt { + block_height: block.block_number, + block_hash: block.block_hash.clone(), + }), + } +} + +pub fn validate_bridge_lifecycle_evidence( + state: &ChainState, + credit_id: &str, + transfer_id: &str, + require_finality: bool, +) -> Result { + let credit = state + .bridge_credits + .get(credit_id) + .ok_or_else(|| ConsensusValidationError::BridgeCreditMissing(credit_id.to_string()))?; + let credit_block = state + .blocks + .iter() + .find(|block| block.block_number == credit.credited_at_block) + .ok_or_else(|| { + ConsensusValidationError::BridgeSpendReferenceInvalid( + "credited block is missing".to_string(), + ) + })?; + let credit_tx_id = bridge_credit_transaction_id(credit); + let credit_receipt_status = + block_receipt_status(credit_block, &credit_tx_id).ok_or_else(|| { + ConsensusValidationError::BridgeSpendReferenceInvalid( + "credited block does not include the credit transaction receipt".to_string(), + ) + })?; + let block_hash_includes_credit_tx_and_receipt = credit_block.tx_ids.contains(&credit_tx_id) + && credit_receipt_status == "applied" + && calculate_block_hash(credit_block) == credit_block.block_hash; + + if !block_hash_includes_credit_tx_and_receipt { + return Err(ConsensusValidationError::BridgeSpendReferenceInvalid( + "credited block hash does not commit to an applied credit receipt".to_string(), + )); + } + + let state_root_before_credit = state + .blocks + .iter() + .filter(|block| block.block_number < credit_block.block_number) + .max_by_key(|block| block.block_number) + .map(|block| block.state_root.clone()) + .unwrap_or_else(|| { + let genesis = genesis_state(); + state_root(&genesis) + }); + let state_root_changed_after_credit = state_root_before_credit != credit_block.state_root; + if !state_root_changed_after_credit { + return Err(ConsensusValidationError::BridgeSpendReferenceInvalid( + "bridge credit did not change the state root".to_string(), + )); + } + + let finality_receipt = match finality_receipt_for_block(state, credit_block) { + Ok(receipt) => Some(receipt), + Err(_) if !require_finality => None, + Err(error) => return Err(error), + }; + + let transfer = state.balance_transfers.get(transfer_id).ok_or_else(|| { + ConsensusValidationError::BridgeSpendReferenceInvalid( + "transfer record is missing".to_string(), + ) + })?; + let transfer_block = state + .blocks + .iter() + .find(|block| block.block_number == transfer.transferred_at_block) + .ok_or_else(|| { + ConsensusValidationError::BridgeSpendReferenceInvalid( + "transfer block is missing".to_string(), + ) + })?; + if transfer_block.block_number <= credit_block.block_number { + return Err(ConsensusValidationError::BridgeSpendReferenceInvalid( + "bridge credit spend must occur after the credited block".to_string(), + )); + } + if transfer.from_account_id != credit.recipient_account_id { + return Err(ConsensusValidationError::BridgeSpendReferenceInvalid( + "transfer source does not match bridge credit recipient".to_string(), + )); + } + let finality_receipt_id = finality_receipt + .map(|receipt| receipt.finality_receipt_id.clone()) + .unwrap_or_default(); + let transfer_tx_id = bridge_spend_transaction_id( + transfer, + credit_id, + &finality_receipt_id, + &credit_block.block_hash, + &credit_block.state_root, + ); + let transfer_receipt_status = block_receipt_status(transfer_block, &transfer_tx_id) + .ok_or_else(|| { + ConsensusValidationError::BridgeSpendReferenceInvalid( + "transfer block does not include the bridge spend receipt".to_string(), + ) + })?; + let references_credit_id = transfer.memo.contains(credit_id); + let references_finality_receipt_id = + !finality_receipt_id.is_empty() && transfer.memo.contains(&finality_receipt_id); + let references_credited_state_root = transfer.memo.contains(&credit_block.state_root); + let spendable_under_finality_rule = finality_receipt.is_some() + && transfer_receipt_status == "applied" + && references_credit_id + && references_finality_receipt_id + && references_credited_state_root; + + if require_finality && !spendable_under_finality_rule { + return Err(ConsensusValidationError::BridgeSpendReferenceInvalid( + "transfer does not reference the credited finalized state".to_string(), + )); + } + + let finality = match finality_receipt { + Some(receipt) => BridgeCreditFinalityEvidence { + required: require_finality, + status: "finalized-private-pilot".to_string(), + finality_receipt_id: Some(receipt.finality_receipt_id.clone()), + finalized_height: receipt.finalized_height, + finalized_block_hash: receipt.finalized_block_hash.clone(), + finalized_state_root: receipt.finalized_state_root.clone(), + rule: receipt.finality_rule.clone(), + private_pilot: true, + }, + None => BridgeCreditFinalityEvidence { + required: require_finality, + status: "unfinalized-private-pilot".to_string(), + finality_receipt_id: None, + finalized_height: 0, + finalized_block_hash: ZERO_HASH.to_string(), + finalized_state_root: ZERO_HASH.to_string(), + rule: state.consensus_state.finality_rule.clone(), + private_pilot: true, + }, + }; + + let transfer_after_credit = BridgeSpendEvidence { + transfer_id: transfer_id.to_string(), + transfer_tx_id, + transfer_block_number: transfer_block.block_number, + transfer_block_hash: transfer_block.block_hash.clone(), + transfer_receipt_status, + references_credit_id, + references_finality_receipt_id, + references_credited_state_root, + spendable_under_finality_rule, + }; + + let evidence_id = hash_json( + "flowmemory.local_devnet.bridge_lifecycle_evidence_id.v0", + &serde_json::json!({ + "creditId": credit_id, + "creditBlockHash": credit_block.block_hash, + "transferId": transfer_id, + "transferBlockHash": transfer_after_credit.transfer_block_hash, + "finalityReceiptId": finality.finality_receipt_id + }), + ); + + Ok(BridgeLifecycleEvidence { + schema: BRIDGE_LIFECYCLE_EVIDENCE_SCHEMA.to_string(), + evidence_id, + credit_id: credit_id.to_string(), + credit_tx_id, + credit_included_in_block: credit_block.block_number, + credit_block_hash: credit_block.block_hash.clone(), + credit_receipt_status, + block_hash_includes_credit_tx_and_receipt, + state_root_before_credit, + state_root_after_credit: credit_block.state_root.clone(), + state_root_changed_after_credit, + finality, + transfer_after_credit, + result: "accepted-private-pilot-finality".to_string(), + }) +} + +pub fn validate_chain(state: &ChainState) -> ConsensusValidationReport { + let mut errors = Vec::new(); + let mut cursor = genesis_state(); + cursor.validator_set = state.validator_set.clone(); + cursor.authority_set = state.authority_set.clone(); + cursor.consensus_state.finalized_height = 0; + cursor.consensus_state.finalized_hash = GENESIS_HASH.to_string(); + cursor.consensus_state.finalized_state_root = state_root(&cursor); + + for block in &state.blocks { + if let Err(error) = validate_block_header(&cursor, block) { + errors.push(error.to_string()); + } + cursor.parent_hash = block.block_hash.clone(); + cursor.next_block_number = block.block_number + 1; + cursor.logical_time = block.logical_time + 1; + cursor.blocks.push(block.clone()); + } + + if let Some(last) = state.blocks.last() { + let current_state_root = state_root(state); + if last.state_root != current_state_root { + errors.push(format!( + "latest block state root {} does not match current state root {}", + last.state_root, current_state_root + )); + } + if state.consensus_state.canonical_head_hash != last.block_hash { + errors.push(format!( + "canonical head {} does not match latest block {}", + state.consensus_state.canonical_head_hash, last.block_hash + )); + } + } + if state.consensus_state.finalized_height > state.consensus_state.canonical_height { + errors.push("finalized height exceeds canonical height".to_string()); + } + let mut finality_targets = BTreeSet::new(); + for receipt in state.chain_finality_receipts.values() { + let key = ( + receipt.finalized_height, + receipt.finalized_block_hash.clone(), + ); + if !finality_targets.insert(key) { + errors.push(format!( + "duplicate finality receipt for block {} hash {}", + receipt.finalized_height, receipt.finalized_block_hash + )); + } + match state.blocks.iter().find(|block| { + block.block_number == receipt.finalized_height + && block.block_hash == receipt.finalized_block_hash + }) { + Some(block) => { + if receipt.finalized_state_root != block.state_root { + errors.push(format!( + "finality receipt {} state root {} does not cover block state root {}", + receipt.finality_receipt_id, receipt.finalized_state_root, block.state_root + )); + } + if receipt.certificate.block_hash != block.block_hash + || receipt.certificate.state_root != block.state_root + || receipt.certificate.block_height != block.block_number + { + errors.push(format!( + "finality certificate {} does not cover block {}", + receipt.certificate.certificate_id, block.block_hash + )); + } + } + None => errors.push(format!( + "finality receipt {} points at unknown block {} hash {}", + receipt.finality_receipt_id, receipt.finalized_height, receipt.finalized_block_hash + )), + } + } + if let Some(latest_id) = &state.consensus_state.latest_finality_receipt_id + && !state.chain_finality_receipts.contains_key(latest_id) + { + errors.push(format!("latest finality receipt is missing: {latest_id}")); + } + ConsensusValidationReport { + schema: "flowmemory.local_devnet.consensus_validation_report.v0".to_string(), + valid: errors.is_empty(), + checked_blocks: state.blocks.len(), + canonical_head_hash: state.consensus_state.canonical_head_hash.clone(), + finalized_height: state.consensus_state.finalized_height, + finalized_hash: state.consensus_state.finalized_hash.clone(), + finalized_state_root: state.consensus_state.finalized_state_root.clone(), + consensus_state_root: consensus_state_root(state), + errors, + } +} + +pub fn choose_canonical_fork(parent_state: &ChainState, candidates: &[Block]) -> ForkChoiceOutcome { + let mut valid = Vec::new(); + let mut rejected = Vec::new(); + for block in candidates { + match validate_block_header(parent_state, block) { + Ok(()) => valid.push(block.clone()), + Err(error) => rejected.push(fork_evidence( + "rejected-invalid-branch", + block, + &error.to_string(), + &parent_state.consensus_state.canonical_head_hash, + parent_state.logical_time, + )), + } + } + valid.sort_by(|left, right| { + right + .block_number + .cmp(&left.block_number) + .then_with(|| left.block_hash.cmp(&right.block_hash)) + }); + let canonical_head = valid.first().cloned(); + if let Some(canonical) = &canonical_head { + for block in valid.iter().skip(1) { + rejected.push(fork_evidence( + "orphaned-valid-branch", + block, + "lower deterministic fork-choice score", + &canonical.block_hash, + parent_state.logical_time, + )); + } + } + ForkChoiceOutcome { + schema: "flowmemory.local_devnet.fork_choice_outcome.v0".to_string(), + canonical_head, + rejected, + } +} + +pub fn record_fork_choice_evidence(state: &mut ChainState, outcome: &ForkChoiceOutcome) { + state.fork_evidence.extend(outcome.rejected.clone()); +} + +pub fn record_block_validation( + state: &mut ChainState, + block: &Block, +) -> Result<(), ConsensusValidationError> { + match validate_block_header(state, block) { + Ok(()) => Ok(()), + Err(error) => { + let detail = error.to_string(); + state.fork_evidence.push(fork_evidence( + "rejected-invalid-branch", + block, + &detail, + &state.consensus_state.canonical_head_hash, + state.logical_time, + )); + state.misbehavior_evidence.push(misbehavior_evidence( + misbehavior_kind(&error), + block, + &detail, + state.logical_time, + )); + Err(error) + } + } +} + +pub fn record_block_proposal_validation( + state: &mut ChainState, + proposal: &BlockProposal, +) -> Result<(), ConsensusValidationError> { + match validate_block_proposal(state, proposal) { + Ok(()) => Ok(()), + Err(error) => { + let detail = error.to_string(); + state.fork_evidence.push(fork_evidence( + "rejected-invalid-branch", + &proposal.block, + &detail, + &state.consensus_state.canonical_head_hash, + state.logical_time, + )); + state.misbehavior_evidence.push(misbehavior_evidence( + misbehavior_kind(&error), + &proposal.block, + &detail, + state.logical_time, + )); + Err(error) + } + } +} + +pub fn record_duplicate_proposal_evidence(state: &mut ChainState, first: &Block, second: &Block) { + if first.proposer_id == second.proposer_id + && first.block_number == second.block_number + && first.block_hash != second.block_hash + { + state.misbehavior_evidence.push(misbehavior_evidence( + "duplicate_proposal_same_height", + second, + &format!( + "proposer {} produced competing blocks {} and {} at height {}", + second.proposer_id, first.block_hash, second.block_hash, second.block_number + ), + state.logical_time, + )); + } +} + +fn fork_evidence( + kind: &str, + block: &Block, + reason: &str, + canonical_head_hash: &str, + observed_at_logical_time: u64, +) -> ForkEvidence { + let evidence_id = hash_json( + "flowmemory.local_devnet.fork_evidence_id.v0", + &serde_json::json!({ + "kind": kind, + "blockHash": block.block_hash, + "reason": reason, + "canonicalHeadHash": canonical_head_hash + }), + ); + ForkEvidence { + schema: FORK_EVIDENCE_SCHEMA.to_string(), + evidence_id, + kind: kind.to_string(), + block_hash: block.block_hash.clone(), + block_height: block.block_number, + parent_hash: block.parent_hash.clone(), + reason: reason.to_string(), + canonical_head_hash: canonical_head_hash.to_string(), + observed_at_logical_time, + } +} + +fn misbehavior_kind(error: &ConsensusValidationError) -> &'static str { + match error { + ConsensusValidationError::WrongChainId { .. } => "wrong_chain_id", + ConsensusValidationError::WrongGenesisHash { .. } => "wrong_genesis_hash", + ConsensusValidationError::InvalidParent { .. } => "invalid_parent", + ConsensusValidationError::InvalidProposer(_) => "invalid_proposer", + ConsensusValidationError::RootMismatch { root_name, .. } if root_name == "stateRoot" => { + "invalid_state_root" + } + ConsensusValidationError::RootMismatch { .. } => "invalid_root", + ConsensusValidationError::DuplicateTransaction(_) => "duplicate_transaction", + ConsensusValidationError::FinalizedConflict { .. } => "finalized_conflict", + _ => "invalid_block", + } +} + +fn misbehavior_evidence( + kind: &str, + block: &Block, + detail: &str, + observed_at_logical_time: u64, +) -> MisbehaviorEvidence { + let evidence_id = hash_json( + "flowmemory.local_devnet.misbehavior_evidence_id.v0", + &serde_json::json!({ + "kind": kind, + "blockHash": block.block_hash, + "blockHeight": block.block_number, + "proposerId": block.proposer_id, + "detail": detail + }), + ); + MisbehaviorEvidence { + schema: MISBEHAVIOR_EVIDENCE_SCHEMA.to_string(), + evidence_id, + kind: kind.to_string(), + block_hash: block.block_hash.clone(), + block_height: block.block_number, + proposer_id: block.proposer_id.clone(), + detail: detail.to_string(), + observed_at_logical_time, + } +} + +fn apply_local_test_unit_transfer( + state: &mut ChainState, + transfer_id: &str, + from_account_id: &str, + to_account_id: &str, + amount_units: u64, + memo: &str, +) -> Result<(), DevnetError> { + if state.balance_transfers.contains_key(transfer_id) { + return Err(DevnetError::BalanceTransferAlreadyExists( + transfer_id.to_string(), + )); + } + if amount_units == 0 { + return Err(DevnetError::FaucetAmountMustBePositive( + transfer_id.to_string(), + )); + } + + let from_balance = state + .local_test_unit_balances + .get(from_account_id) + .ok_or_else(|| DevnetError::LocalTestUnitBalanceMissing(from_account_id.to_string()))?; + if from_balance.units < amount_units { + return Err(DevnetError::LocalTestUnitBalanceInsufficient( + from_account_id.to_string(), + )); + } + + { + let from_balance = state + .local_test_unit_balances + .get_mut(from_account_id) + .expect("source local test-unit balance was checked above"); + from_balance.units -= amount_units; + from_balance.updated_at_block = state.next_block_number; + } + + let to_balance = state + .local_test_unit_balances + .get_mut(to_account_id) + .ok_or_else(|| DevnetError::LocalTestUnitBalanceMissing(to_account_id.to_string()))?; + to_balance.units = to_balance + .units + .checked_add(amount_units) + .ok_or_else(|| DevnetError::LocalTestUnitBalanceOverflow(to_account_id.to_string()))?; + to_balance.updated_at_block = state.next_block_number; + + state.balance_transfers.insert( + transfer_id.to_string(), + BalanceTransfer { + transfer_id: transfer_id.to_string(), + from_account_id: from_account_id.to_string(), + to_account_id: to_account_id.to_string(), + amount_units, + memo: memo.to_string(), + transferred_at_block: state.next_block_number, + no_value: true, + }, + ); + Ok(()) +} + +fn ensure_bridge_credit_spendable( + state: &ChainState, + credit_id: &str, + finality_receipt_id: &str, + credited_block_hash: &str, + credited_state_root: &str, + from_account_id: &str, + amount_units: u64, +) -> Result<(), DevnetError> { + let credit = state + .bridge_credits + .get(credit_id) + .ok_or_else(|| DevnetError::BridgeCreditMissing(credit_id.to_string()))?; + if credit.recipient_account_id != from_account_id { + return Err(DevnetError::BridgeSpendReferenceInvalid( + "bridge credit recipient does not match spend source".to_string(), + )); + } + if amount_units > credit.amount_units { + return Err(DevnetError::BridgeSpendReferenceInvalid( + "bridge spend exceeds credited amount".to_string(), + )); + } + let receipt = state + .chain_finality_receipts + .get(finality_receipt_id) + .ok_or_else(|| { + DevnetError::BridgeFinalityReceiptMissing(finality_receipt_id.to_string()) + })?; + if receipt.finalized_height < credit.credited_at_block + || receipt.finalized_block_hash != credited_block_hash + || receipt.finalized_state_root != credited_state_root + { + return Err(DevnetError::BridgeCreditNotFinal(credit_id.to_string())); + } + if !matches!( + receipt.finality_rule.to_ascii_lowercase().as_str(), + rule if rule.contains("finalizes") || rule.contains("finality") + ) { + return Err(DevnetError::BridgeCreditNotFinal(credit_id.to_string())); + } + Ok(()) +} + +pub fn apply_transaction(state: &mut ChainState, tx: &Transaction) -> Result<(), DevnetError> { + match tx { + Transaction::RegisterRootfield { rootfield_id, owner, schema_hash, @@ -1373,56 +3044,14 @@ pub fn apply_transaction(state: &mut ChainState, tx: &Transaction) -> Result<(), amount_units, memo, } => { - if state.balance_transfers.contains_key(transfer_id) { - return Err(DevnetError::BalanceTransferAlreadyExists( - transfer_id.clone(), - )); - } - if *amount_units == 0 { - return Err(DevnetError::FaucetAmountMustBePositive(transfer_id.clone())); - } - - let from_balance = state - .local_test_unit_balances - .get(from_account_id) - .ok_or_else(|| DevnetError::LocalTestUnitBalanceMissing(from_account_id.clone()))?; - if from_balance.units < *amount_units { - return Err(DevnetError::LocalTestUnitBalanceInsufficient( - from_account_id.clone(), - )); - } - - { - let from_balance = state - .local_test_unit_balances - .get_mut(from_account_id) - .expect("source local test-unit balance was checked above"); - from_balance.units -= *amount_units; - from_balance.updated_at_block = state.next_block_number; - } - - let to_balance = state - .local_test_unit_balances - .get_mut(to_account_id) - .ok_or_else(|| DevnetError::LocalTestUnitBalanceMissing(to_account_id.clone()))?; - to_balance.units = to_balance - .units - .checked_add(*amount_units) - .ok_or_else(|| DevnetError::LocalTestUnitBalanceOverflow(to_account_id.clone()))?; - to_balance.updated_at_block = state.next_block_number; - - 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_units: *amount_units, - memo: memo.clone(), - transferred_at_block: state.next_block_number, - no_value: true, - }, - ); + apply_local_test_unit_transfer( + state, + transfer_id, + from_account_id, + to_account_id, + *amount_units, + memo, + )?; } Transaction::LaunchToken { token_id, @@ -2281,6 +3910,141 @@ pub fn apply_transaction(state: &mut ChainState, tx: &Transaction) -> Result<(), } state.base_anchors.insert(anchor.anchor_id.clone(), anchor); } + Transaction::RecordBridgeReplayKey { + replay_key, + source_chain_id, + source_tx_hash, + source_log_index, + local_object_id, + } => { + if state.bridge_replay_keys.contains_key(replay_key) { + return Err(DevnetError::BridgeReplayKeyAlreadyUsed(replay_key.clone())); + } + state.bridge_replay_keys.insert( + replay_key.clone(), + BridgeReplayKeyRecord { + schema: BRIDGE_REPLAY_KEY_SCHEMA.to_string(), + replay_key: replay_key.clone(), + source_chain_id: source_chain_id.clone(), + source_tx_hash: source_tx_hash.clone(), + source_log_index: source_log_index.clone(), + local_object_id: local_object_id.clone(), + status: "accepted-replay-guard".to_string(), + first_seen_block: state.next_block_number, + finalized_at_height: None, + }, + ); + } + Transaction::ApplyBridgeCredit { + credit_id, + replay_key, + source_chain_id, + source_tx_hash, + source_log_index, + recipient_account_id, + amount_units, + evidence_hash, + } => { + if *amount_units == 0 { + return Err(DevnetError::BridgeCreditAmountMustBePositive( + credit_id.clone(), + )); + } + if state.bridge_credits.contains_key(credit_id) { + return Err(DevnetError::BridgeCreditAlreadyExists(credit_id.clone())); + } + if state.bridge_replay_keys.contains_key(replay_key) { + return Err(DevnetError::BridgeReplayKeyAlreadyUsed(replay_key.clone())); + } + let credited_at_block = state.next_block_number; + let balance = state + .local_test_unit_balances + .entry(recipient_account_id.clone()) + .or_insert_with(|| LocalTestUnitBalance { + account_id: recipient_account_id.clone(), + owner: "bridge-credit-recipient".to_string(), + units: 0, + total_faucet_units: 0, + last_faucet_record_id: None, + updated_at_block: credited_at_block, + no_value: true, + }); + balance.units = balance.units.checked_add(*amount_units).ok_or_else(|| { + DevnetError::LocalTestUnitBalanceOverflow(recipient_account_id.clone()) + })?; + balance.updated_at_block = credited_at_block; + state.bridge_replay_keys.insert( + replay_key.clone(), + BridgeReplayKeyRecord { + schema: BRIDGE_REPLAY_KEY_SCHEMA.to_string(), + replay_key: replay_key.clone(), + source_chain_id: source_chain_id.clone(), + source_tx_hash: source_tx_hash.clone(), + source_log_index: source_log_index.clone(), + local_object_id: credit_id.clone(), + status: "accepted-in-block-awaits-finality-check".to_string(), + first_seen_block: credited_at_block, + finalized_at_height: None, + }, + ); + state.bridge_credits.insert( + credit_id.clone(), + BridgeCreditRecord { + schema: BRIDGE_CREDIT_SCHEMA.to_string(), + credit_id: credit_id.clone(), + replay_key: replay_key.clone(), + source_chain_id: source_chain_id.clone(), + source_tx_hash: source_tx_hash.clone(), + source_log_index: source_log_index.clone(), + recipient_account_id: recipient_account_id.clone(), + amount_units: *amount_units, + evidence_hash: evidence_hash.clone(), + credited_at_block, + status: "accepted-in-block-not-public-final".to_string(), + finality_required: true, + local_only: true, + production_ready: false, + }, + ); + } + Transaction::SpendBridgeCreditLocalTestUnits { + transfer_id, + credit_id, + finality_receipt_id, + credited_block_hash, + credited_state_root, + from_account_id, + to_account_id, + amount_units, + memo, + } => { + ensure_bridge_credit_spendable( + state, + credit_id, + finality_receipt_id, + credited_block_hash, + credited_state_root, + from_account_id, + *amount_units, + )?; + if !memo.contains(credit_id) + || !memo.contains(finality_receipt_id) + || !memo.contains(credited_state_root) + { + return Err(DevnetError::BridgeSpendReferenceInvalid( + "memo must reference credit id, finality receipt id, and credited state root" + .to_string(), + )); + } + apply_local_test_unit_transfer( + state, + transfer_id, + from_account_id, + to_account_id, + *amount_units, + memo, + )?; + } Transaction::ImportFlowPulseObservation(observation) => { if observation.event_signature.to_lowercase() != FLOWPULSE_TOPIC0 { return Err(DevnetError::InvalidEventSignature( diff --git a/crates/flowmemory-devnet/tests/devnet_tests.rs b/crates/flowmemory-devnet/tests/devnet_tests.rs index 99287194..9c09f75a 100644 --- a/crates/flowmemory-devnet/tests/devnet_tests.rs +++ b/crates/flowmemory-devnet/tests/devnet_tests.rs @@ -1,9 +1,14 @@ use flowmemory_devnet::model::{ - DevnetError, FLOWPULSE_TOPIC0, LOCAL_TEST_UNIT_ASSET_ID, Transaction, ZERO_HASH, - apply_transaction, build_block, demo_transactions, deterministic_liquidity_id, - deterministic_lp_position_id, deterministic_pool_id, deterministic_swap_id, - deterministic_token_balance_id, deterministic_token_id, genesis_state, - product_demo_transactions, queue_transaction, state_map_roots, state_root, + ConsensusValidationError, DevnetError, FLOWPULSE_TOPIC0, LOCAL_PRIVATE_VALIDATOR_ID, + LOCAL_TEST_UNIT_ASSET_ID, Transaction, ZERO_HASH, apply_transaction, + bridge_replay_key_is_final, build_block, build_block_with_proposer, choose_canonical_fork, + demo_transactions, deterministic_liquidity_id, deterministic_lp_position_id, + deterministic_pool_id, deterministic_swap_id, deterministic_token_balance_id, + deterministic_token_id, envelope_tx, finalized_height, finalized_state_root, genesis_state, + product_demo_transactions, propose_block, queue_transaction, record_block_proposal_validation, + record_block_validation, record_duplicate_proposal_evidence, record_fork_choice_evidence, + state_map_roots, state_root, validate_block_header, validate_bridge_lifecycle_evidence, + validate_chain, }; use flowmemory_devnet::{canonical_json, keccak_hex}; use std::process::Command; @@ -94,6 +99,353 @@ fn invalid_tx_is_rejected_without_state_mutation() { assert!(state.rootfields.is_empty()); } +#[test] +fn consensus_valid_block_proposal_is_accepted_and_finalized() { + let mut state = genesis_state(); + queue_transaction( + &mut state, + register_rootfield_tx("rootfield:consensus:valid"), + ); + + let block = build_block(&mut state); + let report = validate_chain(&state); + + assert_eq!(block.proposer_id, LOCAL_PRIVATE_VALIDATOR_ID); + assert_eq!(block.chain_id, state.chain_id); + assert_eq!(block.genesis_hash, state.genesis_hash); + assert!(block.tx_root.starts_with("0x")); + assert!(block.receipt_root.starts_with("0x")); + assert!(block.event_root.starts_with("0x")); + assert!(report.valid, "{:?}", report.errors); + assert_eq!(finalized_height(&state), 1); + assert_eq!(state.consensus_state.finalized_hash, block.block_hash); + assert_eq!(finalized_state_root(&state), block.state_root); + assert_eq!(state.chain_finality_receipts.len(), 1); +} + +#[test] +fn consensus_rejects_invalid_proposer_without_consuming_pending_txs() { + let mut state = genesis_state(); + queue_transaction( + &mut state, + register_rootfield_tx("rootfield:consensus:bad-proposer"), + ); + + let error = build_block_with_proposer(&mut state, "validator:intruder").unwrap_err(); + + assert_eq!( + error, + ConsensusValidationError::InvalidProposer("validator:intruder".to_string()) + ); + assert_eq!(state.blocks.len(), 0); + assert_eq!(state.pending_txs.len(), 1); +} + +#[test] +fn consensus_rejects_wrong_parent_and_records_evidence() { + let parent = genesis_state(); + let proposal = propose_block( + &parent, + vec![envelope_tx(register_rootfield_tx( + "rootfield:consensus:parent", + ))], + LOCAL_PRIVATE_VALIDATOR_ID, + ) + .unwrap(); + let mut state = parent.clone(); + let mut forged = proposal.block; + forged.parent_hash = keccak_hex(b"wrong-parent"); + + let error = record_block_validation(&mut state, &forged).unwrap_err(); + + assert!(matches!( + error, + ConsensusValidationError::InvalidParent { .. } + )); + assert_eq!(state.fork_evidence.len(), 1); + assert_eq!(state.misbehavior_evidence[0].kind, "invalid_parent"); +} + +#[test] +fn consensus_rejects_wrong_state_root_in_block_proposal() { + let parent = genesis_state(); + let mut proposal = propose_block( + &parent, + vec![envelope_tx(register_rootfield_tx( + "rootfield:consensus:root", + ))], + LOCAL_PRIVATE_VALIDATOR_ID, + ) + .unwrap(); + proposal.block.state_root = keccak_hex(b"wrong-state-root"); + let mut state = parent; + + let error = record_block_proposal_validation(&mut state, &proposal).unwrap_err(); + + assert!(matches!( + error, + ConsensusValidationError::RootMismatch { root_name, .. } if root_name == "stateRoot" + )); + assert_eq!(state.misbehavior_evidence[0].kind, "invalid_state_root"); +} + +#[test] +fn consensus_fork_choice_is_deterministic_and_records_duplicate_proposal() { + let parent = genesis_state(); + let proposal_a = propose_block( + &parent, + vec![envelope_tx(register_rootfield_tx("rootfield:fork:a"))], + LOCAL_PRIVATE_VALIDATOR_ID, + ) + .unwrap(); + let proposal_b = propose_block( + &parent, + vec![envelope_tx(register_rootfield_tx("rootfield:fork:b"))], + LOCAL_PRIVATE_VALIDATOR_ID, + ) + .unwrap(); + let outcome = choose_canonical_fork( + &parent, + &[proposal_a.block.clone(), proposal_b.block.clone()], + ); + let canonical = outcome.canonical_head.as_ref().expect("canonical head"); + let expected = [&proposal_a.block.block_hash, &proposal_b.block.block_hash] + .into_iter() + .min() + .expect("min hash") + .to_string(); + let mut state = parent; + + record_fork_choice_evidence(&mut state, &outcome); + record_duplicate_proposal_evidence(&mut state, &proposal_a.block, &proposal_b.block); + + assert_eq!(canonical.block_hash, expected); + assert_eq!(outcome.rejected.len(), 1); + assert_eq!(state.fork_evidence.len(), 1); + assert_eq!( + state.misbehavior_evidence[0].kind, + "duplicate_proposal_same_height" + ); +} + +#[test] +fn consensus_rejects_wrong_chain_and_wrong_genesis() { + let parent = genesis_state(); + let proposal = propose_block( + &parent, + vec![envelope_tx(register_rootfield_tx( + "rootfield:consensus:identity", + ))], + LOCAL_PRIVATE_VALIDATOR_ID, + ) + .unwrap(); + + let mut wrong_chain_state = parent.clone(); + let mut wrong_chain = proposal.block.clone(); + wrong_chain.chain_id = "wrong-chain".to_string(); + let error = record_block_validation(&mut wrong_chain_state, &wrong_chain).unwrap_err(); + assert!(matches!( + error, + ConsensusValidationError::WrongChainId { .. } + )); + assert_eq!( + wrong_chain_state.misbehavior_evidence[0].kind, + "wrong_chain_id" + ); + + let mut wrong_genesis_state = parent; + let mut wrong_genesis = proposal.block; + wrong_genesis.genesis_hash = keccak_hex(b"wrong-genesis"); + let error = record_block_validation(&mut wrong_genesis_state, &wrong_genesis).unwrap_err(); + assert!(matches!( + error, + ConsensusValidationError::WrongGenesisHash { .. } + )); + assert_eq!( + wrong_genesis_state.misbehavior_evidence[0].kind, + "wrong_genesis_hash" + ); +} + +#[test] +fn consensus_bridge_replay_rejection_survives_finality_and_restart_shape() { + let mut state = genesis_state(); + let replay_key = "bridge-replay:test:001"; + queue_transaction(&mut state, bridge_replay_tx(replay_key)); + let first = build_block(&mut state); + queue_transaction(&mut state, bridge_replay_tx(replay_key)); + let duplicate = build_block(&mut state); + + assert_eq!(state.bridge_replay_keys.len(), 1); + assert!(bridge_replay_key_is_final(&state, replay_key)); + assert_eq!( + state.consensus_state.finalized_height, + duplicate.block_number + ); + assert_eq!(state.consensus_state.finalized_hash, duplicate.block_hash); + assert_eq!(state.chain_finality_receipts.len(), 2); + assert!(duplicate.receipts.iter().any(|receipt| { + receipt.status == "rejected" + && receipt + .error + .as_ref() + .is_some_and(|error| error.contains("bridge replay key already used")) + })); + assert_eq!(state.blocks[0].block_hash, first.block_hash); +} + +#[test] +fn consensus_rejects_invalid_validator_key_reference() { + let parent = genesis_state(); + let mut proposal = propose_block( + &parent, + vec![envelope_tx(register_rootfield_tx( + "rootfield:consensus:key-ref", + ))], + LOCAL_PRIVATE_VALIDATOR_ID, + ) + .unwrap(); + proposal.block.authority_proof.consensus_key_id = "consensus-key:wrong".to_string(); + + let error = validate_block_header(&parent, &proposal.block).unwrap_err(); + + assert!(matches!( + error, + ConsensusValidationError::InvalidAuthorityProof(_) + )); +} + +#[test] +fn consensus_rejects_mutated_block_header_root() { + let parent = genesis_state(); + let mut proposal = propose_block( + &parent, + vec![envelope_tx(register_rootfield_tx( + "rootfield:consensus:mutated-header", + ))], + LOCAL_PRIVATE_VALIDATOR_ID, + ) + .unwrap(); + proposal.block.tx_root = keccak_hex(b"mutated-tx-root"); + + let error = validate_block_header(&parent, &proposal.block).unwrap_err(); + + assert!(matches!( + error, + ConsensusValidationError::RootMismatch { root_name, .. } if root_name == "txRoot" + )); +} + +#[test] +fn bridge_credit_spend_requires_finalized_credited_state_reference() { + let (state, credit_id, transfer_id) = bridge_credit_spend_state(); + + let evidence = + validate_bridge_lifecycle_evidence(&state, &credit_id, &transfer_id, true).unwrap(); + + assert_eq!(evidence.credit_id, credit_id); + assert!(evidence.block_hash_includes_credit_tx_and_receipt); + assert!(evidence.state_root_changed_after_credit); + assert_eq!(evidence.finality.status, "finalized-private-pilot"); + assert!(evidence.transfer_after_credit.references_credit_id); + assert!( + evidence + .transfer_after_credit + .references_finality_receipt_id + ); + assert!( + evidence + .transfer_after_credit + .references_credited_state_root + ); + assert!(evidence.transfer_after_credit.spendable_under_finality_rule); +} + +#[test] +fn bridge_credit_spend_rejects_missing_finality_receipt_where_required() { + let mut state = genesis_state(); + queue_transaction( + &mut state, + Transaction::CreateLocalTestUnitBalance { + account_id: "local-account:bridge:sink".to_string(), + owner: "operator:bridge:sink".to_string(), + }, + ); + build_block(&mut state); + + let credit_id = keccak_hex(b"bridge-credit:missing-finality"); + queue_transaction( + &mut state, + bridge_credit_tx(&credit_id, "bridge-replay:missing-finality"), + ); + let credit_block = build_block(&mut state); + let finality_receipt_id = state + .consensus_state + .latest_finality_receipt_id + .clone() + .expect("credit finality receipt"); + state.chain_finality_receipts.remove(&finality_receipt_id); + + queue_transaction( + &mut state, + bridge_spend_tx( + "bridge-spend:missing-finality", + &credit_id, + &finality_receipt_id, + &credit_block.block_hash, + &credit_block.state_root, + ), + ); + let spend_block = build_block(&mut state); + + assert!(spend_block.receipts.iter().any(|receipt| { + receipt.status == "rejected" + && receipt + .error + .as_ref() + .is_some_and(|error| error.contains("bridge finality receipt does not exist")) + })); + let error = validate_bridge_lifecycle_evidence( + &state, + &credit_id, + "bridge-spend:missing-finality", + true, + ) + .unwrap_err(); + assert!(matches!( + error, + ConsensusValidationError::MissingFinalityReceipt { .. } + )); +} + +#[test] +fn consensus_rejects_duplicate_finality_receipt_for_same_block() { + let mut state = genesis_state(); + queue_transaction( + &mut state, + register_rootfield_tx("rootfield:consensus:duplicate-finality"), + ); + let block = build_block(&mut state); + let receipt = state + .chain_finality_receipts + .values() + .next() + .cloned() + .expect("finality receipt"); + let mut duplicate = receipt; + duplicate.finality_receipt_id = keccak_hex(b"duplicate-finality-receipt"); + state + .chain_finality_receipts + .insert(duplicate.finality_receipt_id.clone(), duplicate); + + let report = validate_chain(&state); + + assert!(!report.valid); + assert!(report.errors.iter().any(|error| { + error.contains("duplicate finality receipt") && error.contains(&block.block_hash) + })); +} + #[test] fn invalid_dependencies_are_rejected() { let mut state = genesis_state(); @@ -934,6 +1286,9 @@ fn cli_demo_writes_state_and_handoff_files() { 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()); + assert!(out_dir.join("validator-set.json").exists()); + assert!(out_dir.join("consensus-state.json").exists()); + assert!(out_dir.join("finality-status.json").exists()); let body = std::fs::read_to_string(&state).expect("state body"); assert!(body.contains("rootfield:demo:alpha")); @@ -950,6 +1305,8 @@ fn cli_demo_writes_state_and_handoff_files() { assert!(dashboard_body.contains("memoryCells")); assert!(dashboard_body.contains("finalityReceipts")); assert!(dashboard_body.contains("operatorKeyReferences")); + assert!(dashboard_body.contains("validatorSet")); + assert!(dashboard_body.contains("chainFinalityReceipts")); std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); } @@ -976,6 +1333,7 @@ fn cli_smoke_runs_full_flow() { serde_json::from_slice(&output.stdout).expect("smoke summary json"); assert_eq!(summary["deterministicReplay"], true); assert_eq!(summary["blockHeight"], 10); + assert_eq!(summary["finalizedHeight"], 10); assert_eq!(summary["checks"]["genesisConfigInitialized"], true); assert_eq!(summary["checks"]["operatorKeyReferencePresent"], true); assert_eq!(summary["checks"]["localTestUnitBalanceCreated"], true); @@ -983,6 +1341,7 @@ fn cli_smoke_runs_full_flow() { assert_eq!(summary["checks"]["localTestUnitBalanceUnits"], 1000); assert_eq!(summary["checks"]["receiptFinalized"], true); assert!(out_dir.join("control-plane-handoff.json").exists()); + assert!(out_dir.join("finality-status.json").exists()); std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); } @@ -1008,6 +1367,7 @@ fn cli_product_smoke_exports_token_and_dex_handoff() { let summary: serde_json::Value = serde_json::from_slice(&output.stdout).expect("product smoke summary json"); assert_eq!(summary["deterministicReplay"], true); + assert_eq!(summary["finalizedHeight"], 2); assert_eq!(summary["checks"]["localAccountsFunded"], true); assert_eq!(summary["checks"]["tokenLaunched"], true); assert_eq!(summary["checks"]["poolCreated"], true); @@ -1022,6 +1382,161 @@ fn cli_product_smoke_exports_token_and_dex_handoff() { assert!(control_plane_body.contains("tokenDefinitions")); assert!(control_plane_body.contains("dexPools")); assert!(control_plane_body.contains("swapReceipts")); + assert!(control_plane_body.contains("finalizedHeight")); + + std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); +} + +#[test] +fn cli_consensus_commands_write_report_and_finality_proof() { + let temp = temp_dir("cli-consensus"); + let state = temp.join("state.json"); + let out_dir = temp.join("consensus-smoke"); + let proof = temp.join("finality-proof.json"); + + let smoke = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "consensus-smoke", + "--out-dir", + out_dir.to_str().expect("out path"), + ]) + .output() + .expect("run consensus smoke"); + assert!(smoke.status.success()); + let report: serde_json::Value = + serde_json::from_slice(&smoke.stdout).expect("consensus report"); + assert!(report["finalizedHeight"].as_u64().expect("height") >= 3); + assert_eq!(report["validation"]["valid"], true); + assert!( + report["rejectedForks"] + .as_array() + .expect("rejected forks") + .len() + >= 1 + ); + assert!(out_dir.join("consensus-report.json").exists()); + assert!(out_dir.join("finality-proof.json").exists()); + assert!(out_dir.join("fork-choice-proof.json").exists()); + + let validate = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "consensus-validate", + ]) + .output() + .expect("validate consensus"); + assert!(validate.status.success()); + let validation: serde_json::Value = + serde_json::from_slice(&validate.stdout).expect("validation json"); + assert_eq!(validation["valid"], true); + + let validators = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "validator-set", + ]) + .output() + .expect("validator set"); + assert!(validators.status.success()); + let validator_json: serde_json::Value = + serde_json::from_slice(&validators.stdout).expect("validator json"); + assert!(validator_json["validators"][LOCAL_PRIVATE_VALIDATOR_ID].is_object()); + + let finality = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "finality-status", + ]) + .output() + .expect("finality status"); + assert!(finality.status.success()); + let finality_json: serde_json::Value = + serde_json::from_slice(&finality.stdout).expect("finality json"); + assert_eq!(finality_json["finalizedHeight"], report["finalizedHeight"]); + + let proof_status = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "write-finality-proof", + "--out", + proof.to_str().expect("proof path"), + ]) + .status() + .expect("write finality proof"); + assert!(proof_status.success()); + assert!(proof.exists()); + + let fork_choice = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "fork-choice-test", + "--out", + temp.join("fork-choice-proof.json") + .to_str() + .expect("fork proof"), + ]) + .status() + .expect("fork choice proof"); + assert!(fork_choice.success()); + + let live_dir = temp.join("live-l1-consensus"); + let live_verify = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "live-l1-consensus-verify", + "--out-dir", + live_dir.to_str().expect("live out"), + ]) + .output() + .expect("live l1 consensus verify"); + assert!(live_verify.status.success()); + let live_json: serde_json::Value = + serde_json::from_slice(&live_verify.stdout).expect("live verify json"); + assert_eq!(live_json["status"], "passed"); + assert_eq!(live_json["acceptableForPublicL1"], false); + let live_report_body = std::fs::read_to_string(live_dir.join("consensus-finality-report.json")) + .expect("live report body"); + assert!(live_report_body.contains("single-process-private-local-authority-set")); + assert!(!live_report_body.contains("privateKey")); + assert!(live_dir.join("bridge-lifecycle-evidence.json").exists()); + + std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); +} + +#[test] +fn cli_live_l1_verify_fails_existing_public_l1_claim() { + let temp = temp_dir("live-l1-unsafe-report"); + let state = temp.join("state.json"); + let live_dir = temp.join("live-l1-consensus"); + std::fs::create_dir_all(&live_dir).expect("live dir"); + std::fs::write( + live_dir.join("consensus-finality-report.json"), + r#"{"schema":"test","readinessClaims":{"acceptableForPublicL1":true}}"#, + ) + .expect("write unsafe report"); + + let output = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "live-l1-consensus-verify", + "--out-dir", + live_dir.to_str().expect("live out"), + ]) + .output() + .expect("live l1 consensus verify"); + + assert!(!output.status.success()); + let body = String::from_utf8_lossy(&output.stdout); + assert!(body.contains("single-process local/private")); std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); } @@ -1117,6 +1632,9 @@ fn cli_generated_handoff_files_are_deterministic() { "control-plane-handoff.json", "genesis-config.json", "operator-key-references.json", + "validator-set.json", + "consensus-state.json", + "finality-status.json", "state.json", ] { let left = std::fs::read_to_string(out_a.join(file)).expect("left handoff"); @@ -1198,8 +1716,23 @@ fn cli_export_import_state_round_trip_is_deterministic() { 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); + let original_json: serde_json::Value = + serde_json::from_str(&original_body).expect("original json"); + let imported_json: serde_json::Value = + serde_json::from_str(&imported_body).expect("imported json"); + assert_eq!( + imported_json["consensusState"]["finalizedHeight"], + original_json["consensusState"]["finalizedHeight"] + ); + assert_eq!( + imported_json["consensusState"]["finalizedStateRoot"], + original_json["consensusState"]["finalizedStateRoot"] + ); assert!(temp.join("genesis-config.json").exists()); assert!(temp.join("operator-key-references.json").exists()); + assert!(temp.join("validator-set.json").exists()); + assert!(temp.join("consensus-state.json").exists()); + assert!(temp.join("finality-status.json").exists()); std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); } @@ -1644,3 +2177,85 @@ fn finalize_tx(finality_receipt_id: &str, receipt_id: &str) -> Transaction { finality_status: "finalized".to_string(), } } + +fn bridge_replay_tx(replay_key: &str) -> Transaction { + Transaction::RecordBridgeReplayKey { + replay_key: replay_key.to_string(), + source_chain_id: "base-sepolia-or-mock-local".to_string(), + source_tx_hash: keccak_hex(format!("source-tx:{replay_key}").as_bytes()), + source_log_index: "0".to_string(), + local_object_id: format!("bridge-object:{replay_key}"), + } +} + +fn bridge_credit_tx(credit_id: &str, replay_key: &str) -> Transaction { + Transaction::ApplyBridgeCredit { + credit_id: credit_id.to_string(), + replay_key: replay_key.to_string(), + source_chain_id: "8453".to_string(), + source_tx_hash: keccak_hex(format!("source-tx:{credit_id}").as_bytes()), + source_log_index: "0".to_string(), + recipient_account_id: "local-account:bridge:receiver".to_string(), + amount_units: 25, + evidence_hash: keccak_hex(format!("evidence:{credit_id}").as_bytes()), + } +} + +fn bridge_spend_tx( + transfer_id: &str, + credit_id: &str, + finality_receipt_id: &str, + credited_block_hash: &str, + credited_state_root: &str, +) -> Transaction { + Transaction::SpendBridgeCreditLocalTestUnits { + transfer_id: transfer_id.to_string(), + credit_id: credit_id.to_string(), + finality_receipt_id: finality_receipt_id.to_string(), + credited_block_hash: credited_block_hash.to_string(), + credited_state_root: credited_state_root.to_string(), + from_account_id: "local-account:bridge:receiver".to_string(), + to_account_id: "local-account:bridge:sink".to_string(), + amount_units: 10, + memo: format!( + "credit={credit_id};finality={finality_receipt_id};stateRoot={credited_state_root}" + ), + } +} + +fn bridge_credit_spend_state() -> (flowmemory_devnet::model::ChainState, String, String) { + let mut state = genesis_state(); + queue_transaction( + &mut state, + Transaction::CreateLocalTestUnitBalance { + account_id: "local-account:bridge:sink".to_string(), + owner: "operator:bridge:sink".to_string(), + }, + ); + build_block(&mut state); + + let credit_id = keccak_hex(b"bridge-credit:finalized-spend"); + queue_transaction( + &mut state, + bridge_credit_tx(&credit_id, "bridge-replay:finalized-spend"), + ); + let credit_block = build_block(&mut state); + let finality_receipt_id = state + .consensus_state + .latest_finality_receipt_id + .clone() + .expect("credit finality receipt"); + let transfer_id = "bridge-spend:finalized".to_string(); + queue_transaction( + &mut state, + bridge_spend_tx( + &transfer_id, + &credit_id, + &finality_receipt_id, + &credit_block.block_hash, + &credit_block.state_root, + ), + ); + build_block(&mut state); + (state, credit_id, transfer_id) +} diff --git a/devnet/README.md b/devnet/README.md index 3894a681..88aace06 100644 --- a/devnet/README.md +++ b/devnet/README.md @@ -15,6 +15,8 @@ Use: ```powershell cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- init cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- smoke +npm run flowchain:consensus:smoke +npm run flowchain:consensus:live-l1:verify ``` See [docs/LOCAL_DEVNET.md](../docs/LOCAL_DEVNET.md) for full commands. diff --git a/docs/DECISIONS/2026-05-14-flowchain-private-local-consensus-v0.md b/docs/DECISIONS/2026-05-14-flowchain-private-local-consensus-v0.md new file mode 100644 index 00000000..8231a76d --- /dev/null +++ b/docs/DECISIONS/2026-05-14-flowchain-private-local-consensus-v0.md @@ -0,0 +1,54 @@ +# FlowChain Private/Local Consensus V0 + +Date: 2026-05-14 + +## Status + +Accepted for private/local runtime implementation. + +## Context + +The Rust devnet previously produced deterministic local blocks without validator +identity, fork-choice evidence, or chain-finality receipts. The next +private/local L1 package needs real local authority identity and deterministic +block validation without claiming public permissionless validator readiness. + +## Decision + +FlowChain private/local consensus V0 uses a genesis authority set with one +dashboard-safe public validator identity: + +- `validator:local-private:alpha` +- role metadata: `validator`, `sequencer`, `proposer`, `finality-signer` +- consensus key reference only; no secret material in state or handoff output +- explicit separation from user wallet keys and bridge release keys + +Blocks include chain id, genesis hash, authority-set id, proposer id, +transaction root, receipt root, event root, state root, a local authority proof, +and block hash. Validation rejects wrong chain id, wrong genesis hash, wrong +parent, wrong height, timestamp bounds, invalid proposer, root mismatch, +duplicate transaction ids, and block-hash/proof mismatch. + +Fork choice chooses highest valid height, then lexicographically lowest block +hash as a deterministic tie-breaker. Invalid branches and valid orphaned +branches produce machine-readable fork evidence. Duplicate proposals at the same +height by the same proposer produce misbehavior evidence. + +Finality is immediate for validated canonical blocks in the single-authority +private/local profile. Each finalized block produces a chain finality receipt +and certificate. Export/import preserves finalized height, finalized hash, and +finalized state root. + +Bridge replay keys are recorded as local no-value replay guards only. A bridge +credit or release consumer must compare the containing block height with the +consensus finalized height before treating that local record as final. + +## Consequences + +- The local runtime now has a production-grade private/local authority model for + second-computer validation. +- Public validators, staking, slashing, public network consensus, production + bridge custody, and tokenomics remain out of scope. +- Control-plane and dashboard consumers can read consensus and finality fields + from generated runtime output without reading secret material. + diff --git a/docs/LOCAL_DEVNET.md b/docs/LOCAL_DEVNET.md index dd5a2424..cfbe506b 100644 --- a/docs/LOCAL_DEVNET.md +++ b/docs/LOCAL_DEVNET.md @@ -1,8 +1,8 @@ # FlowMemory Local Devnet -Status: runnable no-value private/local runtime +Status: runnable no-value private/local runtime with local authority-set consensus -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 the current private/local FlowChain runtime surface for second-computer validation. +The local FlowMemory devnet is a Rust CLI that models FlowMemory appchain-style state transitions with a private/local authority-set consensus profile. It does not implement public permissionless validators, tokenomics, bridge assets, public validator onboarding, or mainnet claims. It is the current private/local FlowChain runtime surface for second-computer validation. It is local/no-value only. It has local test-unit balance and faucet records for runtime smoke and dashboard/control-plane testing, but those records are not tokens, rewards, staking, gas economics, bridge assets, or production deployment behavior. @@ -15,7 +15,7 @@ Reason: - Rust is a better long-term fit for chain/node work than ad hoc scripts. - The local model needs deterministic state roots, block hashes, and tests. - A full OP Stack/Base Appchain deployment would be premature before schemas and anchors stabilize. -- Custom consensus is explicitly out of scope. +- Public permissionless consensus is explicitly out of scope; the current profile is a deterministic private/local authority set for local L1 validation. Decision record: [2026-05-13-no-value-local-appchain-prototype.md](DECISIONS/2026-05-13-no-value-local-appchain-prototype.md) @@ -115,6 +115,53 @@ cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- smoke `smoke` now builds the native object lifecycle, writes state and handoff files, produces 10 deterministic local blocks, and proves deterministic single-node reconciliation by replaying the same flow twice and comparing block hashes, latest parent hash, state root, and map roots. LAN and multi-node networking are not exposed in this crate yet. +Run the consensus smoke: + +```powershell +npm run flowchain:consensus:smoke +``` + +Equivalent Rust command: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- consensus-smoke --out-dir devnet/local/consensus-smoke +``` + +The consensus smoke writes: + +```text +devnet/local/consensus-smoke/consensus-report.json +devnet/local/consensus-smoke/finality-proof.json +devnet/local/consensus-smoke/fork-choice-proof.json +devnet/local/consensus-smoke/state-snapshot.json +devnet/local/consensus-smoke/handoff/ +``` + +Verify live-L1 consensus/finality readiness: + +```powershell +npm run flowchain:consensus:live-l1:verify +``` + +The live-L1 verifier writes: + +```text +devnet/local/live-l1-consensus/consensus-finality-report.json +devnet/local/live-l1-consensus/bridge-lifecycle-evidence.json +``` + +The verifier fails if an existing readiness report claims public/live finality or public-L1 acceptability while the runtime is still the private/local single-process authority-set profile. In the current profile, private live pilot scope is acceptable and public L1 readiness is blocked. + +Inspect consensus state: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- consensus-validate +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- validator-set +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- finality-status +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- fork-choice-test --out devnet/local/fork-choice-proof.json +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- write-finality-proof --out devnet/local/finality-proof.json +``` + Import a FlowPulse observation fixture: ```powershell @@ -245,14 +292,34 @@ Supported local transactions: Each block has: - Block number. +- Chain id. +- Genesis hash. +- Authority-set id. +- Proposer id. - Parent hash. - Logical time. - Transaction ids. +- Transaction root. - Receipts. +- Receipt root. +- Event root. - State root. +- Local authority proof. - Block hash. -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. +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. Blocks are proposed by the configured private/local authority set, validate parent/hash/height/time/proposer/roots, and finalize immediately under the local single-authority profile. + +Consensus state includes: + +- `validatorSet` +- `authoritySet` +- `consensusState` +- `chainFinalityReceipts` +- `forkEvidence` +- `misbehaviorEvidence` +- `bridgeReplayKeys` + +The local finality rule is immediate finality for a validated canonical block signed by the configured private/local authority proof. Static local peer sync adopts only valid chains and uses highest height, then lexicographically lowest canonical block hash as a deterministic tie-breaker. This is not public validator readiness. `inspect-state --summary`, exported handoff files, and Base anchor placeholders include deterministic roots for the local maps, including operator key references, agent accounts, local test-unit balances, faucet records, model passports, memory cells, challenges, finality receipts, artifact availability proofs, verifier modules, work receipts, and verifier reports. @@ -276,9 +343,12 @@ Generated exports: - `fixtures/handoff/generated/control-plane-handoff.json` - `fixtures/handoff/generated/genesis-config.json` - `fixtures/handoff/generated/operator-key-references.json` +- `fixtures/handoff/generated/validator-set.json` +- `fixtures/handoff/generated/consensus-state.json` +- `fixtures/handoff/generated/finality-status.json` - `fixtures/handoff/generated/state.json` -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 generated dashboard, indexer, verifier, control-plane, and state outputs include consensus/finality fields, 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. @@ -291,7 +361,9 @@ Control-plane and dashboard agents should read: ## Non-Goals - No production consensus. -- No validator set. +- No public or permissionless validator set. +- No public validator onboarding. +- No staking, slashing, rewards, validator economics, or public validator readiness. - No tokenomics. - No validator rewards. - No staking or slashing. diff --git a/docs/agent-runs/production-l1-consensus/BLOCK_VALIDATION_PROOF.md b/docs/agent-runs/production-l1-consensus/BLOCK_VALIDATION_PROOF.md new file mode 100644 index 00000000..08710ee1 --- /dev/null +++ b/docs/agent-runs/production-l1-consensus/BLOCK_VALIDATION_PROOF.md @@ -0,0 +1,40 @@ +# Block Validation Proof + +## Validation Rules + +Accepted blocks must validate: + +- chain id +- genesis hash +- authority-set id +- expected height +- parent hash +- timestamp bounds +- scheduled proposer identity +- proposer role membership +- no duplicate transaction ids +- transaction root +- receipt root +- event root +- state root for full block proposals +- local authority proof digest/signature +- block hash +- finalized-height conflicts + +## Evidence + +Rust tests cover: + +- valid block proposal accepted and finalized +- invalid proposer rejected without consuming pending transactions +- wrong parent rejected with fork and misbehavior evidence +- wrong chain id rejected +- wrong genesis hash rejected +- wrong state root rejected at proposal validation + +Runnable validation command: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- consensus-validate +``` + diff --git a/docs/agent-runs/production-l1-consensus/BRIDGE_FINALITY_PROOF.md b/docs/agent-runs/production-l1-consensus/BRIDGE_FINALITY_PROOF.md new file mode 100644 index 00000000..56d75603 --- /dev/null +++ b/docs/agent-runs/production-l1-consensus/BRIDGE_FINALITY_PROOF.md @@ -0,0 +1,32 @@ +# Bridge Finality Proof + +## Implemented Boundary + +This pass does not implement production bridge custody or public bridge +security. It implements a local replay guard transaction: + +```text +RecordBridgeReplayKey +``` + +The replay key is stored in devnet state and included in deterministic map roots. +Duplicate replay keys are rejected in later blocks and remain rejected after +restart/export/import because the replay-key map is persisted. + +## Finality Rule For Bridge Consumers + +Bridge credits are not final merely because a replay key exists. A bridge credit +consumer must treat a local bridge object as final only when: + +```text +replayKey.firstSeenBlock <= consensusState.finalizedHeight +``` + +Release evidence must reference a finalized withdrawal intent in a future bridge +implementation, or explicitly state local-private pilot semantics. Consensus +keys do not sign bridge release instructions. + +Rust coverage: + +- `consensus_bridge_replay_rejection_survives_finality_and_restart_shape` + diff --git a/docs/agent-runs/production-l1-consensus/CHECKLIST.md b/docs/agent-runs/production-l1-consensus/CHECKLIST.md new file mode 100644 index 00000000..eb590f2a --- /dev/null +++ b/docs/agent-runs/production-l1-consensus/CHECKLIST.md @@ -0,0 +1,21 @@ +# Production L1 Consensus Checklist + +- [x] Tracking files created. +- [x] Existing Rust devnet consensus gaps mapped. +- [x] Validator public identity format implemented. +- [x] Genesis authority set implemented and exported. +- [x] Proposer identity included in block headers. +- [x] Block proposal validates parent, height, timestamp, proposer, stable transaction ordering, and duplicate transaction ids. +- [x] Block validation covers chain id, genesis hash, block hash, tx root, receipt root, event root, state root, proposer, and bridge replay keys. +- [x] Fork choice handles competing valid blocks, invalid branches, unknown parents/stale branches via rejection evidence, finalized conflicts, and deterministic tie-breaks. +- [x] Misbehavior evidence records duplicate proposals, invalid parent, wrong chain id, wrong genesis hash, invalid proposer, and invalid root. +- [x] Local/private finality rule implemented with receipt/certificate shape and finalized height/hash/root queries. +- [x] Export/import preserves finalized height and finalized root. +- [x] Control-plane/runtime handoff output includes consensus and finality fields. +- [x] Runnable consensus commands implemented. +- [x] Required proof docs written. +- [x] `devnet/local/consensus-smoke/consensus-report.json` generated. +- [x] `cargo test --manifest-path crates/flowmemory-devnet/Cargo.toml` passes. +- [x] `npm run flowchain:consensus:smoke` passes if added. +- [x] `npm run flowchain:multi-node:smoke` checked if available. +- [x] `git diff --check` passes. diff --git a/docs/agent-runs/production-l1-consensus/EXPERIMENTS.md b/docs/agent-runs/production-l1-consensus/EXPERIMENTS.md new file mode 100644 index 00000000..36633a78 --- /dev/null +++ b/docs/agent-runs/production-l1-consensus/EXPERIMENTS.md @@ -0,0 +1,9 @@ +# Production L1 Consensus Experiments + +| Experiment | Command | Result | Notes | +| --- | --- | --- | --- | +| Baseline Rust tests | `cargo test --manifest-path crates/flowmemory-devnet/Cargo.toml` | Pass | Initial baseline passed before edits. | +| Final Rust tests | `cargo clean --manifest-path crates/flowmemory-devnet/Cargo.toml -p flowmemory-devnet; cargo test --manifest-path crates/flowmemory-devnet/Cargo.toml` | Pass | Cleaned the shared `CARGO_TARGET_DIR` package artifact so tests used this worktree's `flowmemory-devnet` binary; 35 integration tests passed. | +| Consensus smoke | `npm run flowchain:consensus:smoke` | Pass | Writes `devnet/local/consensus-smoke/consensus-report.json` with finalized height 4 and validation `true`. | +| Multi-node smoke | `npm run flowchain:multi-node:smoke` | Pass | Existing local-file peer smoke passed and wrote `devnet/local/multi-node-smoke/multi-node-smoke-report.json`. | +| Diff whitespace | `git diff --check` | Pass | No whitespace errors; Git reported only LF-to-CRLF working-copy warnings. | diff --git a/docs/agent-runs/production-l1-consensus/FINALITY_PROOF.md b/docs/agent-runs/production-l1-consensus/FINALITY_PROOF.md new file mode 100644 index 00000000..624e18c1 --- /dev/null +++ b/docs/agent-runs/production-l1-consensus/FINALITY_PROOF.md @@ -0,0 +1,25 @@ +# Finality Proof + +## Local Finality Rule + +The private/local single-authority profile finalizes each validated canonical +block immediately after proposal validation and authority proof verification. + +Each finalized block writes: + +- `consensusState.finalizedHeight` +- `consensusState.finalizedHash` +- `consensusState.finalizedStateRoot` +- `chainFinalityReceipts[receiptId]` +- finality certificate with authority-set id, block hash, state root, signer id, + quorum weight, and total weight + +## Commands + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- finality-status +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- write-finality-proof --out devnet/local/finality-proof.json +``` + +Export/import preservation is covered by `cli_export_import_state_round_trip_is_deterministic`. + diff --git a/docs/agent-runs/production-l1-consensus/FORK_CHOICE_PROOF.md b/docs/agent-runs/production-l1-consensus/FORK_CHOICE_PROOF.md new file mode 100644 index 00000000..ab1c1ed0 --- /dev/null +++ b/docs/agent-runs/production-l1-consensus/FORK_CHOICE_PROOF.md @@ -0,0 +1,22 @@ +# Fork Choice Proof + +## Rule + +Fork choice is deterministic: + +1. Choose the highest valid height. +2. For equal-height valid branches, choose the lexicographically lowest block + hash. +3. Reject invalid branches and record fork evidence. +4. Record duplicate proposal misbehavior when the same proposer produces + competing blocks at the same height. +5. Reject branches that conflict with finalized height. + +## Command + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- fork-choice-test --out devnet/local/fork-choice-proof.json +``` + +The consensus smoke writes `devnet/local/consensus-smoke/fork-choice-proof.json`. + diff --git a/docs/agent-runs/production-l1-consensus/HANDOFF.md b/docs/agent-runs/production-l1-consensus/HANDOFF.md new file mode 100644 index 00000000..f50c44e3 --- /dev/null +++ b/docs/agent-runs/production-l1-consensus/HANDOFF.md @@ -0,0 +1,56 @@ +# Production L1 Consensus Handoff + +## Runtime Output + +Default runtime files under `devnet/local/`: + +- `state.json` +- `genesis-config.json` +- `operator-key-references.json` +- `validator-set.json` +- `consensus-state.json` +- `finality-status.json` + +Consensus smoke output: + +- `devnet/local/consensus-smoke/consensus-report.json` +- `devnet/local/consensus-smoke/finality-proof.json` +- `devnet/local/consensus-smoke/fork-choice-proof.json` +- `devnet/local/consensus-smoke/state-snapshot.json` +- `devnet/local/consensus-smoke/handoff/control-plane-handoff.json` + +## Finality Fields + +Control-plane and dashboard handoffs include: + +- `consensus.state.finalizedHeight` +- `consensus.state.finalizedHash` +- `consensus.state.finalizedStateRoot` +- `finality.finalizedHeight` +- `finality.finalizedHash` +- `finality.finalizedStateRoot` +- `chainFinalityReceipts` +- `consensusStateRoot` + +## Validation Rules + +Blocks validate chain id, genesis hash, authority-set id, height, parent hash, +timestamp bounds, proposer role/schedule, duplicate transaction ids, tx root, +receipt root, event root, state root for proposals, authority proof, block hash, +and finalized-height conflicts. + +## RPC/Dashboard Fields + +RPC/dashboard agents should surface: + +- validator set id and validator public metadata +- canonical height and canonical head hash +- finalized height, finalized hash, finalized state root +- latest chain-finality receipt id +- fork evidence count and latest rejected fork records +- misbehavior evidence count and latest records +- bridge replay key finality by comparing `firstSeenBlock` to finalized height + +This is private/local authority-set consensus. It is not public validator +readiness. + diff --git a/docs/agent-runs/production-l1-consensus/LIVE_L1_CONSENSUS_FINALITY.md b/docs/agent-runs/production-l1-consensus/LIVE_L1_CONSENSUS_FINALITY.md new file mode 100644 index 00000000..500d6116 --- /dev/null +++ b/docs/agent-runs/production-l1-consensus/LIVE_L1_CONSENSUS_FINALITY.md @@ -0,0 +1,87 @@ +# Live L1 Consensus And Finality Evidence + +Date: 2026-05-14 + +Final status: PASS + +Public L1 status: BLOCKED + +## Scope + +This run made the live local L1 path honest about consensus and finality. The current runtime is a single-process private/local authority-set pilot. It is acceptable for private live pilot validation and is not acceptable for public L1 claims. + +Generated local reports: + +- `devnet/local/live-l1-consensus/consensus-finality-report.json` +- `devnet/local/live-l1-consensus/bridge-lifecycle-evidence.json` + +## Consensus Readiness + +The live readiness report now states: + +- Current consensus mode: `single-process-private-local-authority-set` +- Validator set source: local genesis metadata from `crates/flowmemory-devnet/src/model.rs` +- Validator key material: public consensus key and key references only +- Block signing status: local private authority proof present and validated +- Finality rule: validated canonical blocks finalize immediately under the single local authority profile +- Fork choice: valid highest height, with deterministic hash tie-break for static local-file peer sync +- Peer mode: single-process local-file private mode, no public peer discovery +- Private live pilot: acceptable with local/private scope +- Public L1: blocked + +The verifier command fails if an existing report claims public/live finality, production readiness, or public-L1 acceptability while the runtime is still this local single-process mode. + +## Bridge Finality Lifecycle + +Bridge local credit evidence now records and validates: + +- Credit transaction included in block N +- Credit block hash covers the credited transaction and receipt +- State root changes after the credit +- Finality receipt covers the credited block under the private/local rule +- Transfer after credit references the credited block hash, credited state root, and finality receipt id + +The local bridge credit is spendable only through this accepted/finalized private-pilot evidence path. It does not claim production bridge or public L1 finality. + +## Tests And Gates + +Passed: + +- `cargo test --manifest-path crates/flowmemory-devnet/Cargo.toml` +- `npm run flowchain:node:start -- -MaxBlocks 3 -Wait` +- `npm run flowchain:node:start`; `npm run flowchain:node:status`; `npm run flowchain:node:stop` +- `npm run flowchain:node:status` +- `npm run flowchain:consensus:live-l1:verify` +- `npm run flowchain:production-l1:e2e` +- `npm run flowchain:no-secret:scan` +- `git diff --check` + +The production L1 e2e report status is `passed-with-public-l1-blocked`. + +## Negative Coverage + +Rust tests cover: + +- Invalid validator key reference +- Mutated block header +- Wrong state root in a block proposal +- Missing finality receipt where bridge spend finality is required +- Duplicate finality receipt for the same block +- Existing live-L1 report that falsely claims public finality + +## Source-Of-Truth Notes + +The requested production protocol files were absent from this worktree and from `origin/main` at run time: + +- `docs/agent-runs/production-l1-protocol/GENESIS_PROOF.md` +- `schemas/flowmemory/production-validator-authority.schema.json` +- `schemas/flowmemory/production-finality-receipt.schema.json` +- `schemas/flowmemory/production-block-header.schema.json` + +Matching files were read from sibling unmerged protocol worktree context only. The live report records this and keeps public-L1 readiness blocked until those artifacts and real multi-validator mechanics are merged and wired. + +## Risks And Follow-Ups + +- This is not public consensus. There is one local authority and no public validator onboarding, BFT network, staking, slashing, or audited production cryptography. +- Public L1 readiness remains blocked until the protocol schemas, genesis proof, production validator authority model, finality certificate model, peer networking, and fork-choice rules are merged and enforced end to end. +- Reports intentionally export no private validator keys or seed material. diff --git a/docs/agent-runs/production-l1-consensus/NOTES.md b/docs/agent-runs/production-l1-consensus/NOTES.md new file mode 100644 index 00000000..2f0674e8 --- /dev/null +++ b/docs/agent-runs/production-l1-consensus/NOTES.md @@ -0,0 +1,18 @@ +# Production L1 Consensus Notes + +## Working Boundaries + +- This pass targets private/local authority-set consensus only. +- It does not implement public validator onboarding, staking, slashing, public permissionless consensus, production bridge custody, tokenomics, or audited cryptography. +- Validator keys are represented by public identity and local key-reference metadata. Secret key material must stay out of committed files. + +## Source Context + +- GitHub/open PR status was checked with `infra/scripts/status-report.ps1`. +- Sibling production runtime notes exist but are early tracking only; there is no final runtime handoff to consume yet. +- No production protocol handoff was present in the production protocol worktree. + +## Test Notes + +- `flowchain:consensus:smoke` uses an ignored crate-local Cargo target directory to avoid stale binaries from the shared multi-worktree `CARGO_TARGET_DIR`. +- Direct Cargo tests were verified after cleaning the shared package artifact because other worktrees use the same `flowmemory-devnet` crate/package name. diff --git a/docs/agent-runs/production-l1-consensus/PLAN.md b/docs/agent-runs/production-l1-consensus/PLAN.md new file mode 100644 index 00000000..e7f64e45 --- /dev/null +++ b/docs/agent-runs/production-l1-consensus/PLAN.md @@ -0,0 +1,31 @@ +# Production L1 Consensus Plan + +## Scope + +- Work inside `crates/flowmemory-devnet/`, `devnet/`, `docs/DECISIONS/`, `docs/LOCAL_DEVNET.md`, `docs/agent-runs/production-l1-consensus/`, and `package.json` only if a consensus smoke alias is needed. +- Implement private/local authority-set consensus behavior in the existing Rust devnet. +- Preserve no-value, local/private semantics and avoid public-validator, tokenomics, production bridge, or audited-crypto claims. + +## Context Read + +- `AGENTS.md` +- `docs/START_HERE.md` +- `docs/FLOWMEMORY_HQ_CONTEXT.md` +- `docs/CURRENT_STATE.md` +- `docs/LOCAL_DEVNET.md` +- `docs/ROOTFLOW_V0.md` +- `docs/FLOW_MEMORY_V0.md` +- `docs/V0_LAUNCH_ACCEPTANCE.md` +- Relevant FlowChain and decision records for local/private boundaries +- Production runtime tracking notes, read-only, from the sibling runtime worktree + +## Phases + +1. Map current block, state, root, transaction, and export/import behavior. +2. Add local/private validator identity and authority-set metadata. +3. Validate proposed blocks by chain id, genesis hash, parent, height, timestamp, proposer, transaction ordering, roots, and duplicate/replay boundaries. +4. Add deterministic fork choice, rejected/orphan branch evidence, and misbehavior records. +5. Add local finality state, certificate/receipt output, finalized height/hash/root queries, and restart/export/import preservation. +6. Add CLI commands and root smoke alias for consensus validation, validator set, finality status, fork-choice proof, and finality proof output. +7. Add tests, generated smoke report, proof documents, and handoff. + diff --git a/docs/agent-runs/production-l1-consensus/VALIDATOR_SET.md b/docs/agent-runs/production-l1-consensus/VALIDATOR_SET.md new file mode 100644 index 00000000..901b67f0 --- /dev/null +++ b/docs/agent-runs/production-l1-consensus/VALIDATOR_SET.md @@ -0,0 +1,31 @@ +# Validator Set + +## Implemented Shape + +- Authority-set schema: `flowmemory.local_devnet.authority_set.v0` +- Validator schema: `flowmemory.local_devnet.validator_identity.v0` +- Authority set id: `authority-set:flowmemory-local-private-v0` +- Validator id: `validator:local-private:alpha` +- Roles: `validator`, `sequencer`, `proposer`, `finality-signer` +- Profile: private/local authority set + +The validator identity stores only dashboard-safe metadata, a consensus public +key reference, role metadata, and key-separation notes. It does not store +signing secrets, user wallet keys, or bridge release keys. + +## Runtime Fields + +The devnet state and handoff output expose: + +- `validatorSet` +- `authoritySet` +- `consensusState` +- `chainFinalityReceipts` +- `consensusStateRoot` + +Public metadata is written by: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- validator-set +``` + diff --git a/infra/scripts/flowchain-live-l1-consensus-verify.ps1 b/infra/scripts/flowchain-live-l1-consensus-verify.ps1 new file mode 100644 index 00000000..eb828f4f --- /dev/null +++ b/infra/scripts/flowchain-live-l1-consensus-verify.ps1 @@ -0,0 +1,30 @@ +param( + [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node", + [string] $OutDir = "devnet/local/live-l1-consensus" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot -Purpose "live-l1-consensus-verify" | 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) +$outFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $OutDir) + +Invoke-FlowChainCommand -Label "Verify live L1 consensus/finality readiness" -FilePath "cargo" -ArgumentList @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateFullPath, + "--node-dir", + $nodeFullDir, + "live-l1-consensus-verify", + "--out-dir", + $outFullDir +) diff --git a/infra/scripts/flowchain-no-secret-scan.ps1 b/infra/scripts/flowchain-no-secret-scan.ps1 new file mode 100644 index 00000000..97e40e80 --- /dev/null +++ b/infra/scripts/flowchain-no-secret-scan.ps1 @@ -0,0 +1,110 @@ +param( + [string[]] $Paths = @( + "apps/dashboard/public/data", + "devnet/local/live-l1-consensus", + "devnet/local/production-l1-e2e", + "devnet/local/full-smoke", + "devnet/local/product-e2e", + "fixtures/dashboard", + "services/bridge-relayer/out", + "services/control-plane/out" + ), + [string] $ReportPath = "devnet/local/production-l1-e2e/no-secret-scan-report.json" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +$reportFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $ReportPath) +$findings = New-Object System.Collections.ArrayList +$scanned = New-Object System.Collections.ArrayList + +function Test-ExcludedSecretScanPath { + param([string] $Path) + + $normalized = ($Path -replace "\\", "/").ToLowerInvariant() + $name = [System.IO.Path]::GetFileName($normalized) + if ($normalized -match '(^|/)(\.git|node_modules|target|dist|cache|out/broadcast|broadcast)(/|$)') { return $true } + if ($name -eq ".env" -or ($name.StartsWith(".env.") -and $name -ne ".env.example")) { return $true } + if ($name -eq "no-secret-scan-report.json") { return $true } + if ($name.EndsWith(".local.json")) { return $true } + if ($name -like "*vault*") { return $true } + if ($name.EndsWith(".zip")) { return $true } + return $false +} + +function Add-Finding { + param([string] $Path, [string] $Reason) + [void] $findings.Add([ordered]@{ + path = $Path + reason = $Reason + }) +} + +foreach ($path in $Paths) { + $full = Resolve-FlowChainPath -RepoRoot $repoRoot -Path $path + if (-not (Test-Path -LiteralPath $full)) { + continue + } + + $item = Get-Item -LiteralPath $full + $files = if ($item.PSIsContainer) { + @(Get-ChildItem -LiteralPath $full -Recurse -File | Where-Object { + $_.Extension -in @(".json", ".txt", ".md", ".log", ".csv", ".jsonl") -and + -not (Test-ExcludedSecretScanPath -Path $_.FullName) + }) + } + else { + @($item) + } + + foreach ($file in $files) { + [void] $scanned.Add($file.FullName) + try { + $text = [System.IO.File]::ReadAllText($file.FullName) + } + catch { + Add-Finding -Path $file.FullName -Reason "read failed" + continue + } + + foreach ($pattern in @( + "BEGIN RSA PRIVATE KEY", + "BEGIN OPENSSH PRIVATE KEY", + "BEGIN PRIVATE KEY", + "seedPhrase", + "mnemonicPhrase", + "privateKey", + "private_key", + "webhookUrl", + "webhook_url" + )) { + if ($text.IndexOf($pattern, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { + Add-Finding -Path $file.FullName -Reason "secret marker '$pattern'" + } + } + if ($text -match 'https?://[^\s"<>]*(alchemy|infura|apikey|api-key|token=|key=)[^\s"<>]*') { + Add-Finding -Path $file.FullName -Reason "credential-shaped RPC or API URL" + } + } +} + +$status = if ($findings.Count -eq 0) { "passed" } else { "failed" } +$report = [ordered]@{ + schema = "flowchain.no_secret_scan_report.v0" + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + status = $status + scannedCount = $scanned.Count + scannedPaths = @($Paths) + findings = @($findings) +} +Write-FlowChainJson -Path $reportFullPath -Value $report -Depth 12 + +Write-Host "FlowChain no-secret scan status: $status" +Write-Host "Report: $reportFullPath" +if ($status -ne "passed") { + throw "No-secret scan found findings." +} diff --git a/infra/scripts/flowchain-node-start.ps1 b/infra/scripts/flowchain-node-start.ps1 new file mode 100644 index 00000000..0538ad65 --- /dev/null +++ b/infra/scripts/flowchain-node-start.ps1 @@ -0,0 +1,125 @@ +param( + [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node", + [string] $NodeId = "node:local:alpha", + [int] $BlockMs = 1000, + [int] $MaxBlocks = 0, + [switch] $Wait +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot -Purpose "node-start" | 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) +$logsDir = Join-Path $nodeFullDir "logs" +$reportPath = Join-Path $nodeFullDir "flowchain-node-start-report.json" +$stdoutPath = Join-Path $logsDir "node.stdout.jsonl" +$stderrPath = Join-Path $logsDir "node.stderr.log" +$pidPath = Join-Path $nodeFullDir "flowchain-node.pid" + +New-Item -ItemType Directory -Force -Path $logsDir | Out-Null + +if (-not (Test-Path -LiteralPath $stateFullPath)) { + & powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "flowchain-init.ps1") -StatePath $stateFullPath + if ($LASTEXITCODE -ne 0) { + throw "flowchain-init failed before node start." + } +} + +$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 ($Wait) { + $startedAt = (Get-Date).ToUniversalTime().ToString("o") + $previousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = (& cargo @arguments 2>&1) | ForEach-Object { "$_" } + $exitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + } + $output | Set-Content -LiteralPath $stdoutPath -Encoding utf8 + "" | Set-Content -LiteralPath $stderrPath -Encoding utf8 + $status = if ($exitCode -eq 0) { "completed" } else { "failed" } + $report = [ordered]@{ + schema = "flowchain.private_testnet.node_start_report.v0" + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + startedAt = $startedAt + status = $status + pid = $PID + exitCode = $exitCode + statePath = $stateFullPath + nodeDir = $nodeFullDir + nodeId = $NodeId + blockMs = $BlockMs + maxBlocks = $MaxBlocks + waited = $true + stdoutLog = $stdoutPath + stderrLog = $stderrPath + statusCommand = "npm run flowchain:node:status" + } + Write-FlowChainJson -Path $reportPath -Value $report -Depth 12 + if ($exitCode -ne 0) { + throw "FlowChain node start failed. See $stdoutPath" + } + Write-Host "FlowChain node completed bounded run." + Write-Host "Report: $reportPath" + return +} + +$cargoPath = (Get-Command "cargo" -ErrorAction Stop).Source +$process = Start-Process -FilePath $cargoPath ` + -ArgumentList (Join-FlowChainProcessArguments -ArgumentList $arguments) ` + -WorkingDirectory $repoRoot ` + -PassThru ` + -WindowStyle Hidden ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath + +Set-Content -LiteralPath $pidPath -Value "$($process.Id)" +$report = [ordered]@{ + schema = "flowchain.private_testnet.node_start_report.v0" + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + status = "started" + pid = $process.Id + exitCode = $null + statePath = $stateFullPath + nodeDir = $nodeFullDir + nodeId = $NodeId + blockMs = $BlockMs + maxBlocks = $MaxBlocks + waited = $false + stdoutLog = $stdoutPath + stderrLog = $stderrPath + pidPath = $pidPath + stopCommand = "npm run flowchain:node:stop" + statusCommand = "npm run flowchain:node:status" +} +Write-FlowChainJson -Path $reportPath -Value $report -Depth 12 +Write-Host "FlowChain node started." +Write-Host "PID: $($process.Id)" +Write-Host "Status command: npm run flowchain:node:status" diff --git a/infra/scripts/flowchain-production-l1-e2e.ps1 b/infra/scripts/flowchain-production-l1-e2e.ps1 new file mode 100644 index 00000000..19af1fa7 --- /dev/null +++ b/infra/scripts/flowchain-production-l1-e2e.ps1 @@ -0,0 +1,114 @@ +param( + [string] $ReportDir = "devnet/local/production-l1-e2e" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot -Purpose "production-l1-e2e" | Out-Null +$reportFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $ReportDir) +$logsDir = Join-Path $reportFullDir "logs" +$reportPath = Join-Path $reportFullDir "flowchain-production-l1-e2e-report.json" + +if (Test-Path -LiteralPath $reportFullDir) { + Remove-Item -LiteralPath $reportFullDir -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $logsDir | Out-Null + +$steps = New-Object System.Collections.ArrayList + +function Get-SafeStepName { + param([string] $Name) + return (($Name -replace '[^A-Za-z0-9_.-]', '-') -replace '-+', '-').Trim("-") +} + +function Invoke-ProductionL1Step { + param( + [string] $Name, + [string] $Command, + [string] $FilePath, + [string[]] $ArgumentList = @() + ) + + $safeName = Get-SafeStepName -Name $Name + $logPath = Join-Path $logsDir "$safeName.log" + $startedAt = (Get-Date).ToUniversalTime().ToString("o") + Write-Host "" + Write-Host "== $Name ==" + Write-Host $Command + + $previousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = (& $FilePath @ArgumentList 2>&1) | ForEach-Object { "$_" } + $exitCode = $LASTEXITCODE + } + catch { + $output = @($_.Exception.Message) + $exitCode = 1 + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + } + $output | Set-Content -LiteralPath $logPath -Encoding utf8 + $status = if ($exitCode -eq 0) { "passed" } else { "failed" } + [void] $steps.Add([ordered]@{ + name = $Name + command = $Command + status = $status + exitCode = $exitCode + logPath = $logPath + startedAt = $startedAt + endedAt = (Get-Date).ToUniversalTime().ToString("o") + reason = if ($exitCode -eq 0) { "" } else { (($output | Select-Object -Last 12) -join [Environment]::NewLine) } + }) + if ($exitCode -ne 0) { + Write-Host "FAILED: $Name" + } + else { + Write-Host "PASSED: $Name" + } +} + +Invoke-ProductionL1Step -Name "Devnet consensus tests" -Command "cargo test --manifest-path crates/flowmemory-devnet/Cargo.toml" -FilePath "cargo" -ArgumentList @("test", "--manifest-path", "crates/flowmemory-devnet/Cargo.toml") +Invoke-ProductionL1Step -Name "Node start bounded" -Command "npm run flowchain:node:start -- -MaxBlocks 3 -Wait" -FilePath "npm" -ArgumentList @("run", "flowchain:node:start", "--", "-MaxBlocks", "3", "-Wait") +Invoke-ProductionL1Step -Name "Node status" -Command "npm run flowchain:node:status" -FilePath "npm" -ArgumentList @("run", "flowchain:node:status") +Invoke-ProductionL1Step -Name "Live L1 consensus readiness" -Command "npm run flowchain:consensus:live-l1:verify" -FilePath "npm" -ArgumentList @("run", "flowchain:consensus:live-l1:verify") +Invoke-ProductionL1Step -Name "Bridge local credit handoff" -Command "npm run bridge:local-credit:smoke" -FilePath "npm" -ArgumentList @("run", "bridge:local-credit:smoke") +Invoke-ProductionL1Step -Name "No-secret scan" -Command "npm run flowchain:no-secret:scan" -FilePath "npm" -ArgumentList @("run", "flowchain:no-secret:scan") +Invoke-ProductionL1Step -Name "Patch whitespace check" -Command "git diff --check" -FilePath "git" -ArgumentList @("diff", "--check") + +$failed = @($steps | Where-Object { $_.status -ne "passed" }) +$overall = if ($failed.Count -eq 0) { "passed-with-public-l1-blocked" } else { "failed" } +$liveReportPath = Resolve-FlowChainPath -RepoRoot $repoRoot -Path "devnet/local/live-l1-consensus/consensus-finality-report.json" +$bridgeEvidencePath = Resolve-FlowChainPath -RepoRoot $repoRoot -Path "devnet/local/live-l1-consensus/bridge-lifecycle-evidence.json" + +$report = [ordered]@{ + schema = "flowchain.production_l1.e2e_report.v0" + timestamp = (Get-Date).ToUniversalTime().ToString("o") + status = $overall + publicL1Status = "blocked-single-process-private-local" + privateLivePilotStatus = if ($failed.Count -eq 0) { "passed" } else { "failed" } + reportDir = $reportFullDir + liveConsensusReport = $liveReportPath + bridgeLifecycleEvidence = $bridgeEvidencePath + steps = @($steps) + productionBoundary = @( + "single-process private/local authority set", + "no public L1 finality claim", + "no public validator readiness claim", + "no production bridge security claim" + ) +} +Write-FlowChainJson -Path $reportPath -Value $report -Depth 16 + +Write-Host "" +Write-Host "FlowChain production-l1:e2e status: $overall" +Write-Host "Report: $reportPath" +Write-Host "Live consensus report: $liveReportPath" +if ($failed.Count -gt 0) { + throw "FlowChain production-l1:e2e failed. See $reportPath" +} diff --git a/package.json b/package.json index d79d409c..9921cd0e 100644 --- a/package.json +++ b/package.json @@ -37,18 +37,22 @@ "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:start": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-node-start.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:consensus:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$env:CARGO_TARGET_DIR='crates/flowmemory-devnet/target/consensus-smoke'; cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- consensus-smoke --out-dir devnet/local/consensus-smoke\"", + "flowchain:consensus:live-l1:verify": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-live-l1-consensus-verify.ps1", "flowchain:hardware:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File hardware/simulator/flowrouter-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": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-full-smoke.ps1", "flowchain:product-e2e": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-product-e2e.ps1", "flowchain:l1-e2e": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-full-smoke.ps1", + "flowchain:production-l1:e2e": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-production-l1-e2e.ps1", "flowchain:real-value-pilot:e2e": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-real-value-pilot-e2e.ps1", "flowchain:real-value-pilot": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-real-value-pilot.ps1", "flowchain:real-value-pilot:ops": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-real-value-pilot-ops-e2e.ps1", @@ -56,6 +60,7 @@ "flowchain:real-value-pilot:export": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-real-value-pilot-export.ps1", "flowchain:real-value-pilot:control-dashboard": "npm run real-value-pilot:e2e --prefix services/control-plane", "flowchain:real-value-pilot:wallet": "npm run wallet:pilot-e2e --prefix crypto", + "flowchain:no-secret:scan": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-no-secret-scan.ps1", "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",