From a9e1023525e223bb53a58fbca9295524030f168b Mon Sep 17 00:00:00 2001 From: FlowMemory HQ Agent Date: Thu, 14 May 2026 13:42:38 -0500 Subject: [PATCH] Add production L1 runtime implementation snapshot --- crates/flowmemory-devnet/Cargo.lock | 236 +++ crates/flowmemory-devnet/Cargo.toml | 1 + crates/flowmemory-devnet/src/cli.rs | 1515 ++++++++++++++++- crates/flowmemory-devnet/src/hash.rs | 5 + crates/flowmemory-devnet/src/lib.rs | 26 +- crates/flowmemory-devnet/src/model.rs | 983 ++++++++++- .../flowmemory-devnet/tests/devnet_tests.rs | 275 ++- docs/FLOWCHAIN_TESTNET_ACCEPTANCE.md | 26 +- docs/LOCAL_DEVNET.md | 182 +- .../BLOCK_PRODUCTION_PROOF.md | 41 + .../BRIDGE_CREDIT_SPEND_PROOF.md | 47 + .../production-l1-runtime/CHECKLIST.md | 14 + .../production-l1-runtime/EXPERIMENTS.md | 11 + .../production-l1-runtime/HANDOFF.md | 107 ++ .../LIVE_NODE_BRIDGE_INTAKE.md | 108 ++ .../production-l1-runtime/MEMPOOL_PROOF.md | 38 + .../production-l1-runtime/NODE_COMMANDS.md | 47 + .../agent-runs/production-l1-runtime/NOTES.md | 25 + docs/agent-runs/production-l1-runtime/PLAN.md | 17 + .../production-l1-runtime/RESTART_PROOF.md | 53 + .../TX_LIFECYCLE_PROOF.md | 43 + infra/scripts/flowchain-bridge-ingest.ps1 | 168 ++ .../scripts/flowchain-live-bridge-status.ps1 | 110 ++ infra/scripts/flowchain-no-secret-scan.ps1 | 26 + infra/scripts/flowchain-node-restart.ps1 | 44 + infra/scripts/flowchain-node-smoke.ps1 | 203 ++- infra/scripts/flowchain-node-start.ps1 | 131 ++ infra/scripts/flowchain-restart-verify.ps1 | 122 ++ .../scripts/flowchain-wallet-transfer-e2e.ps1 | 137 ++ package.json | 7 + 30 files changed, 4618 insertions(+), 130 deletions(-) create mode 100644 docs/agent-runs/production-l1-runtime/BLOCK_PRODUCTION_PROOF.md create mode 100644 docs/agent-runs/production-l1-runtime/BRIDGE_CREDIT_SPEND_PROOF.md create mode 100644 docs/agent-runs/production-l1-runtime/CHECKLIST.md create mode 100644 docs/agent-runs/production-l1-runtime/EXPERIMENTS.md create mode 100644 docs/agent-runs/production-l1-runtime/HANDOFF.md create mode 100644 docs/agent-runs/production-l1-runtime/LIVE_NODE_BRIDGE_INTAKE.md create mode 100644 docs/agent-runs/production-l1-runtime/MEMPOOL_PROOF.md create mode 100644 docs/agent-runs/production-l1-runtime/NODE_COMMANDS.md create mode 100644 docs/agent-runs/production-l1-runtime/NOTES.md create mode 100644 docs/agent-runs/production-l1-runtime/PLAN.md create mode 100644 docs/agent-runs/production-l1-runtime/RESTART_PROOF.md create mode 100644 docs/agent-runs/production-l1-runtime/TX_LIFECYCLE_PROOF.md create mode 100644 infra/scripts/flowchain-bridge-ingest.ps1 create mode 100644 infra/scripts/flowchain-live-bridge-status.ps1 create mode 100644 infra/scripts/flowchain-no-secret-scan.ps1 create mode 100644 infra/scripts/flowchain-node-restart.ps1 create mode 100644 infra/scripts/flowchain-node-start.ps1 create mode 100644 infra/scripts/flowchain-restart-verify.ps1 create mode 100644 infra/scripts/flowchain-wallet-transfer-e2e.ps1 diff --git a/crates/flowmemory-devnet/Cargo.lock b/crates/flowmemory-devnet/Cargo.lock index fc97126f..06a838e7 100644 --- a/crates/flowmemory-devnet/Cargo.lock +++ b/crates/flowmemory-devnet/Cargo.lock @@ -8,6 +8,18 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "block-buffer" version = "0.10.4" @@ -17,6 +29,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -26,6 +50,18 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -36,6 +72,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -43,7 +89,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", ] [[package]] @@ -52,6 +143,7 @@ version = "0.1.0" dependencies = [ "anyhow", "hex", + "k256", "serde", "serde_json", "sha3", @@ -66,6 +158,29 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", ] [[package]] @@ -74,12 +189,35 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", + "signature", +] + [[package]] name = "keccak" version = "0.1.6" @@ -101,6 +239,22 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -119,6 +273,39 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "serde" version = "1.0.228" @@ -162,6 +349,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha3" version = "0.10.9" @@ -172,6 +370,32 @@ dependencies = [ "keccak", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -221,6 +445,18 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zmij" version = "1.0.21" diff --git a/crates/flowmemory-devnet/Cargo.toml b/crates/flowmemory-devnet/Cargo.toml index d0fcddd8..51f33e9c 100644 --- a/crates/flowmemory-devnet/Cargo.toml +++ b/crates/flowmemory-devnet/Cargo.toml @@ -13,3 +13,4 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha3 = "0.10" thiserror = "2.0" +k256 = { version = "0.13", features = ["ecdsa"] } diff --git a/crates/flowmemory-devnet/src/cli.rs b/crates/flowmemory-devnet/src/cli.rs index 9f92b9fe..80c77810 100644 --- a/crates/flowmemory-devnet/src/cli.rs +++ b/crates/flowmemory-devnet/src/cli.rs @@ -1,19 +1,22 @@ -use crate::hash::{hash_json, normalize_value}; +use crate::hash::{canonical_json_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, + FLOWPULSE_TOPIC0, ImportedFlowPulseObservation, ImportedVerifierReport, + LOCAL_TEST_UNIT_ASSET_ID, LocalAuthorization, Transaction, build_block, demo_transactions, + deterministic_token_id, envelope_tx, genesis_state, product_demo_transactions, + queue_authorized_transaction, queue_transaction, record_pending_transaction, state_map_roots, state_root, }; use crate::storage::{default_state_path, load_or_genesis, load_state, reset_state, save_state}; use anyhow::{Context, Result, anyhow}; +use k256::ecdsa::{Signature, VerifyingKey, signature::hazmat::PrehashVerifier}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::BTreeSet; use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::thread; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; #[derive(Debug)] pub struct Cli { @@ -37,6 +40,12 @@ pub enum Command { }, NodeStop, NodeStatus, + NodeRestart { + node_id: String, + block_ms: u64, + max_blocks: Option, + peer_config: Option, + }, Tick { node_id: String, peer_config: Option, @@ -46,6 +55,12 @@ pub enum Command { authorized_by: Option, direct: bool, }, + BridgeIngest { + handoff: PathBuf, + authorized_by: Option, + direct: bool, + require_live: bool, + }, Faucet { account_id: String, amount: u64, @@ -59,6 +74,11 @@ pub enum Command { InspectState { summary: bool, }, + ListMempool, + Query { + kind: QueryKind, + id: String, + }, ExportFixtures { out_dir: PathBuf, }, @@ -77,6 +97,21 @@ pub enum Command { ProductSmoke { out_dir: PathBuf, }, + NodeSmoke { + out_dir: PathBuf, + }, +} + +#[derive(Debug, Clone)] +pub enum QueryKind { + Block, + Transaction, + Receipt, + Account, + Token, + Pool, + BridgeCredit, + FinalityReceipt, } pub fn run_cli() -> Result<()> { @@ -136,6 +171,14 @@ fn parse_args(args: Vec) -> Result { }, "node-stop" => Command::NodeStop, "node-status" => Command::NodeStatus, + "node-restart" => Command::NodeRestart { + node_id: option_value_optional(&positional[1..], "--node-id") + .unwrap_or_else(|| "node:local:alpha".to_string()), + block_ms: option_u64(&positional[1..], "--block-ms")?.unwrap_or(1_000), + max_blocks: option_u64(&positional[1..], "--max-blocks")?.or(Some(1)), + peer_config: option_value_optional(&positional[1..], "--peer-config") + .map(PathBuf::from), + }, "tick" => Command::Tick { node_id: option_value_optional(&positional[1..], "--node-id") .unwrap_or_else(|| "node:local:alpha".to_string()), @@ -150,6 +193,12 @@ fn parse_args(args: Vec) -> Result { direct: positional.iter().any(|arg| arg == "--direct"), } } + "bridge-ingest" | "bridge-handoff" => Command::BridgeIngest { + handoff: PathBuf::from(option_value(&positional[1..], "--handoff")?), + authorized_by: option_value_optional(&positional[1..], "--authorized-by"), + direct: positional.iter().any(|arg| arg == "--direct"), + require_live: positional.iter().any(|arg| arg == "--require-live"), + }, "faucet" => Command::Faucet { account_id: option_value(&positional[1..], "--account")?, amount: option_u64(&positional[1..], "--amount")? @@ -168,6 +217,43 @@ fn parse_args(args: Vec) -> Result { "inspect" | "inspect-state" => Command::InspectState { summary: positional.iter().any(|arg| arg == "--summary"), }, + "list-mempool" | "mempool" => Command::ListMempool, + "query" => Command::Query { + kind: query_kind(&option_value(&positional[1..], "--kind")?)?, + id: option_value(&positional[1..], "--id")?, + }, + "query-block" => Command::Query { + kind: QueryKind::Block, + id: option_value(&positional[1..], "--id")?, + }, + "query-tx" | "query-transaction" => Command::Query { + kind: QueryKind::Transaction, + id: option_value(&positional[1..], "--id")?, + }, + "query-receipt" => Command::Query { + kind: QueryKind::Receipt, + id: option_value(&positional[1..], "--id")?, + }, + "query-account" => Command::Query { + kind: QueryKind::Account, + id: option_value(&positional[1..], "--id")?, + }, + "query-token" => Command::Query { + kind: QueryKind::Token, + id: option_value(&positional[1..], "--id")?, + }, + "query-pool" => Command::Query { + kind: QueryKind::Pool, + id: option_value(&positional[1..], "--id")?, + }, + "query-bridge-credit" => Command::Query { + kind: QueryKind::BridgeCredit, + id: option_value(&positional[1..], "--id")?, + }, + "query-finality" | "query-finality-receipt" => Command::Query { + kind: QueryKind::FinalityReceipt, + id: option_value(&positional[1..], "--id")?, + }, "export" | "export-fixtures" => Command::ExportFixtures { out_dir: option_value(&positional[1..], "--out-dir") .map(PathBuf::from) @@ -196,6 +282,11 @@ fn parse_args(args: Vec) -> Result { .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("fixtures/handoff/generated-product")), }, + "node-smoke" => Command::NodeSmoke { + out_dir: option_value(&positional[1..], "--out-dir") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("devnet/local/node-smoke")), + }, unknown => return Err(anyhow!("unknown command '{unknown}'")), }; @@ -238,9 +329,23 @@ fn option_u64(args: &[String], name: &str) -> Result> { .with_context(|| format!("{name} must be a positive integer")) } +fn query_kind(value: &str) -> Result { + match value { + "block" => Ok(QueryKind::Block), + "tx" | "transaction" => Ok(QueryKind::Transaction), + "receipt" => Ok(QueryKind::Receipt), + "account" => Ok(QueryKind::Account), + "token" => Ok(QueryKind::Token), + "pool" => Ok(QueryKind::Pool), + "bridge-credit" | "bridgeCredit" => Ok(QueryKind::BridgeCredit), + "finality" | "finality-receipt" | "finalityReceipt" => Ok(QueryKind::FinalityReceipt), + other => Err(anyhow!("unsupported query kind '{other}'")), + } +} + 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 node-restart [--node-id ] [--block-ms ] [--max-blocks ] [--peer-config ]\n tick [--node-id ] [--peer-config ]\n submit-tx --tx-file [--authorized-by ] [--direct]\n bridge-ingest --handoff [--authorized-by ] [--direct] [--require-live]\n faucet --account --amount [--reason ] [--authorized-by ] [--direct]\n list-mempool\n query --kind --id \n query-block|query-tx|query-receipt|query-account|query-token|query-pool|query-bridge-credit|query-finality --id \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 node-smoke [--out-dir ]\n" ); } @@ -308,6 +413,25 @@ fn run(cli: Cli) -> Result<()> { persisted_status, ))?; } + Command::NodeRestart { + node_id, + block_ms, + max_blocks, + peer_config, + } => { + request_node_stop(&cli.node_dir)?; + if stop_file(&cli.node_dir).exists() { + fs::remove_file(stop_file(&cli.node_dir))?; + } + run_node(NodeRunOptions { + state_path: cli.state, + node_dir: cli.node_dir, + node_id, + block_ms, + max_blocks, + peer_config, + })?; + } Command::Tick { node_id, peer_config, @@ -333,6 +457,17 @@ fn run(cli: Cli) -> Result<()> { ); write_node_identity(&cli.node_dir, &node_id, &cli.state, peer_config.as_deref())?; write_node_status(&cli.node_dir, &status)?; + append_node_log( + &cli.node_dir, + &serde_json::json!({ + "schema": "flowmemory.local_devnet.node_log.v0", + "nodeId": node_id, + "event": "manualTick", + "blockHash": produced.block_hash, + "txs": produced.tx_ids.len(), + "stateRoot": produced.state_root + }), + )?; print_json(&NodeTickSummary::from_block(status, produced.block_hash))?; } Command::SubmitTx { @@ -340,13 +475,41 @@ fn run(cli: Cli) -> Result<()> { authorized_by, direct, } => { - let txs = transactions_from_fixture(&tx_file)?; let queued = if direct { - queue_txs_direct(&cli.state, txs, authorized_by)? + queue_tx_file_direct(&cli.state, &tx_file, authorized_by)? } else { - write_txs_to_inbox(&cli.node_dir, txs, authorized_by)? + write_tx_file_to_inbox(&cli.node_dir, &tx_file, authorized_by)? + }; + print_json(&queued)?; + } + Command::BridgeIngest { + handoff, + authorized_by, + direct, + require_live, + } => { + let txs = bridge_handoff_transactions_from_path(&handoff, require_live)?; + let credit_ids = txs + .iter() + .filter_map(bridge_credit_id_from_tx) + .collect::>(); + let queued = if direct { + queue_txs_direct_result(&cli.state, txs, authorized_by)? + } else { + QueuedTransactions::accepted_only(write_txs_to_inbox( + &cli.node_dir, + txs, + authorized_by, + )?) }; - print_json(&QueuedTransactions { queued })?; + print_json(&BridgeIngestSummary { + schema: "flowmemory.local_devnet.bridge_ingest.v0".to_string(), + handoff, + direct, + require_live, + credit_ids, + queued, + })?; } Command::Faucet { account_id, @@ -384,7 +547,7 @@ fn run(cli: Cli) -> Result<()> { } else { write_txs_to_inbox(&cli.node_dir, txs, authorized_by)? }; - print_json(&QueuedTransactions { queued })?; + print_json(&QueuedTransactions::accepted_only(queued))?; } Command::Start { blocks } => { let mut state = load_or_genesis(&cli.state)?; @@ -400,7 +563,7 @@ fn run(cli: Cli) -> Result<()> { queued.push(queue_transaction(&mut state, tx)); } save_state(&cli.state, &state)?; - print_json(&QueuedTransactions { queued })?; + print_json(&QueuedTransactions::accepted_only(queued))?; } Command::InspectState { summary } => { let state = load_or_genesis(&cli.state)?; @@ -410,6 +573,14 @@ fn run(cli: Cli) -> Result<()> { print_json(&state)?; } } + Command::ListMempool => { + let state = load_or_genesis(&cli.state)?; + print_json(&MempoolSummary::from_state(&state))?; + } + Command::Query { kind, id } => { + let state = load_or_genesis(&cli.state)?; + print_json(&query_state(&state, kind, &id)?)?; + } Command::ExportFixtures { out_dir } => { let state = load_or_genesis(&cli.state)?; export_handoff(&state, &out_dir)?; @@ -470,6 +641,10 @@ fn run(cli: Cli) -> Result<()> { deterministic_replay, ))?; } + Command::NodeSmoke { out_dir } => { + let report = run_node_smoke(&cli.state, &cli.node_dir, &out_dir)?; + print_json(&report)?; + } } Ok(()) } @@ -531,6 +706,25 @@ struct InboxIngestSummary { rejected: usize, } +const MAX_MEMPOOL_TXS: usize = 1_024; +const LOCAL_TRANSACTION_TYPE: &str = "FlowChainLocalTransactionEnvelopeV0(uint256 chainId,bytes32 domainSeparator,bytes32 signerId,bytes32 signerKeyId,uint8 signerRole,uint64 nonce,bytes32 payloadHash,bytes32 objectId,bytes32 objectTypeHash,uint64 issuedAtUnixMs)"; + +#[derive(Debug)] +struct ParsedEnvelope { + envelope: crate::model::TxEnvelope, + signer: Option, + nonce: Option, + replay_key: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct RejectedTx { + tx_id: Option, + reason: String, + source: Option, +} + fn peer_config_schema() -> String { "flowmemory.local_devnet.static_peers.v0".to_string() } @@ -602,9 +796,7 @@ fn run_node(options: NodeRunOptions) -> Result<()> { ); write_node_status(&options.node_dir, &status)?; - println!( - "{}", - serde_json::to_string(&serde_json::json!({ + let log_line = serde_json::json!({ "schema": "flowmemory.local_devnet.node_log.v0", "nodeId": options.node_id, "event": "blockProduced", @@ -612,8 +804,9 @@ fn run_node(options: NodeRunOptions) -> Result<()> { "blockHash": block.block_hash, "txs": block.tx_ids.len(), "stateRoot": block.state_root - }))? - ); + }); + append_node_log(&options.node_dir, &log_line)?; + println!("{}", serde_json::to_string(&log_line)?); if options .max_blocks @@ -657,16 +850,81 @@ fn queue_txs_direct( let mut state = load_or_genesis(state_path)?; let mut queued = Vec::new(); for tx in txs { - let tx_id = match authorized_by.clone() { - Some(signer) => queue_authorized_transaction(&mut state, tx, signer), - None => queue_transaction(&mut state, tx), - }; - queued.push(tx_id); + let envelope = local_authorized_envelope(tx, authorized_by.clone()); + let parsed = parsed_local_envelope(envelope); + queued.push(try_queue_envelope(&mut state, parsed)?); } save_state(state_path, &state)?; Ok(queued) } +fn queue_txs_direct_result( + state_path: &Path, + txs: Vec, + authorized_by: Option, +) -> Result { + let mut state = load_or_genesis(state_path)?; + let mut result = QueuedTransactions::default(); + let mut simulation = preflight_state_with_pending(&state); + for tx in txs { + let envelope = local_authorized_envelope(tx, authorized_by.clone()); + let parsed = parsed_local_envelope(envelope); + match validate_and_queue_envelope(&mut state, &mut simulation, parsed, None) { + Ok(tx_id) => result.queued.push(tx_id), + Err(rejected) => result.rejected.push(rejected), + } + } + save_state(state_path, &state)?; + Ok(result) +} + +fn queue_tx_file_direct( + state_path: &Path, + tx_file: &Path, + authorized_by: Option, +) -> Result { + let mut state = load_or_genesis(state_path)?; + let mut result = QueuedTransactions::default(); + let parsed = parsed_envelopes_from_file(tx_file, authorized_by)?; + let mut simulation = preflight_state_with_pending(&state); + for parsed in parsed { + match validate_and_queue_envelope(&mut state, &mut simulation, parsed, None) { + Ok(tx_id) => result.queued.push(tx_id), + Err(rejected) => result.rejected.push(rejected), + } + } + save_state(state_path, &state)?; + Ok(result) +} + +fn write_tx_file_to_inbox( + node_dir: &Path, + tx_file: &Path, + authorized_by: Option, +) -> Result { + let inbox = inbox_dir(node_dir); + fs::create_dir_all(&inbox) + .with_context(|| format!("failed to create inbox directory {}", inbox.display()))?; + let body = fs::read_to_string(tx_file) + .with_context(|| format!("failed to read transaction file {}", tx_file.display()))?; + let value: Value = serde_json::from_str(body.trim_start_matches('\u{feff}')) + .with_context(|| format!("failed to parse transaction file {}", tx_file.display()))?; + let tx_ids = tx_ids_from_value(&value, authorized_by.clone())?; + let suffix = file_safe_id(&hash_json( + "flowmemory.local_devnet.inbox_file.v0", + &serde_json::json!({ + "path": tx_file, + "txIds": tx_ids + }), + )); + let path = inbox.join(format!("{suffix}.json")); + write_json(path, &value)?; + Ok(QueuedTransactions { + queued: tx_ids, + rejected: Vec::new(), + }) +} + fn write_txs_to_inbox( node_dir: &Path, txs: Vec, @@ -717,16 +975,44 @@ fn drain_inbox( files.sort(); let mut summary = InboxIngestSummary::default(); + let mut simulation = preflight_state_with_pending(state); for path in files { - match transactions_from_inbox_file(&path) { - Ok(txs) => { - for (tx, authorization) in txs { - let mut envelope = envelope_tx(tx); - envelope.authorization = authorization; - state.pending_txs.push(envelope); - summary.queued += 1; + match parsed_envelopes_from_inbox_file(&path) { + Ok(envelopes) => { + let mut file_rejections = Vec::new(); + for envelope in envelopes { + match validate_and_queue_envelope( + state, + &mut simulation, + envelope, + Some(path.clone()), + ) { + Ok(_) => summary.queued += 1, + Err(rejected) => { + summary.rejected += 1; + file_rejections.push(rejected); + } + } + } + if file_rejections.is_empty() { + move_inbox_file(&path, &processed_dir(node_dir))?; + } else { + let error_path = rejected_dir(node_dir).join(format!( + "{}.error.json", + path.file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("rejected") + )); + write_json( + error_path, + &serde_json::json!({ + "schema": "flowmemory.local_devnet.rejected_inbox_tx.v0", + "source": path, + "rejections": file_rejections + }), + )?; + move_inbox_file(&path, &rejected_dir(node_dir))?; } - move_inbox_file(&path, &processed_dir(node_dir))?; } Err(error) => { let error_path = rejected_dir(node_dir).join(format!( @@ -775,6 +1061,13 @@ fn local_authorized_envelope( mode: "local-authorized".to_string(), signer, digest: envelope.tx_id.clone(), + chain_id: None, + nonce: None, + signer_role: None, + signer_key_id: None, + public_key: None, + signature: None, + replay_key: None, }); } envelope @@ -860,6 +1153,21 @@ fn write_node_status(node_dir: &Path, status: &NodeStatus) -> Result<()> { write_json(node_dir.join("status.json"), status) } +fn append_node_log(node_dir: &Path, value: &Value) -> Result<()> { + fs::create_dir_all(node_dir)?; + let path = node_dir.join("node.log.jsonl"); + let mut line = serde_json::to_string(value)?; + line.push('\n'); + use std::io::Write; + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("failed to open node log {}", path.display()))?; + file.write_all(line.as_bytes()) + .with_context(|| format!("failed to append node log {}", path.display())) +} + fn read_node_status(node_dir: &Path) -> Result> { let path = node_dir.join("status.json"); if !path.exists() { @@ -897,31 +1205,120 @@ fn file_safe_id(id: &str) -> String { .collect() } -fn transactions_from_inbox_file( - path: &Path, -) -> Result)>> { +fn parsed_envelopes_from_inbox_file(path: &Path) -> Result> { let body = fs::read_to_string(path) .with_context(|| format!("failed to read inbox transaction {}", path.display()))?; let value: Value = serde_json::from_str(body.trim_start_matches('\u{feff}')) .with_context(|| format!("failed to parse inbox transaction {}", path.display()))?; - let authorization = authorization_from_value(value.get("authorization"))?; + parsed_envelopes_from_value(&value, None) +} + +fn parsed_envelopes_from_file( + path: &Path, + authorized_by: Option, +) -> Result> { + let body = fs::read_to_string(path) + .with_context(|| format!("failed to read transaction file {}", path.display()))?; + let value: Value = serde_json::from_str(body.trim_start_matches('\u{feff}')) + .with_context(|| format!("failed to parse transaction file {}", path.display()))?; + parsed_envelopes_from_value(&value, authorized_by) +} +fn parsed_envelopes_from_value( + value: &Value, + authorized_by: Option, +) -> Result> { + if value.get("envelope").is_some() { + return Ok(vec![signed_envelope_from_value(value)?]); + } + + if value.get("schema").and_then(Value::as_str) + == Some("flowchain.local_transaction_envelope.v0") + { + return Ok(vec![signed_envelope_from_value(&serde_json::json!({ + "envelope": value, + "document": value.get("payload") + }))?]); + } + + let authorization = authorization_from_value(value.get("authorization"))?; if value.get("txs").is_some() { - let txs: Vec = serde_json::from_value(value["txs"].clone()) - .with_context(|| format!("failed to parse txs in {}", path.display()))?; + let txs: Vec = + serde_json::from_value(value["txs"].clone()).context("failed to parse txs")?; return Ok(txs .into_iter() - .map(|tx| (tx, authorization.clone())) + .map(|tx| { + let mut envelope = local_authorized_envelope( + tx, + authorization + .as_ref() + .map(|authorization| authorization.signer.clone()) + .or_else(|| authorized_by.clone()), + ); + if authorization.is_some() { + envelope.authorization = authorization.clone(); + } + parsed_local_envelope(envelope) + }) .collect()); } if value.get("tx").is_some() { - let tx = serde_json::from_value(value["tx"].clone()) - .with_context(|| format!("failed to parse tx in {}", path.display()))?; - return Ok(vec![(tx, authorization)]); + let tx = serde_json::from_value(value["tx"].clone()).context("failed to parse tx")?; + let mut envelope = local_authorized_envelope( + tx, + authorization + .as_ref() + .map(|authorization| authorization.signer.clone()) + .or(authorized_by), + ); + if authorization.is_some() { + envelope.authorization = authorization; + } + return Ok(vec![parsed_local_envelope(envelope)]); } - transactions_from_fixture(path).map(|txs| txs.into_iter().map(|tx| (tx, None)).collect()) + Err(anyhow!( + "unsupported transaction file shape: expected envelope, tx, or txs" + )) +} + +fn parsed_local_envelope(envelope: crate::model::TxEnvelope) -> ParsedEnvelope { + let signer = envelope + .authorization + .as_ref() + .map(|authorization| authorization.signer.clone()); + let nonce = envelope + .authorization + .as_ref() + .and_then(|authorization| authorization.nonce); + let replay_key = envelope + .authorization + .as_ref() + .and_then(|authorization| authorization.replay_key.clone()) + .or_else(|| { + let authorization = envelope.authorization.as_ref()?; + Some(format!( + "{}:local-authorized:{}:{}", + authorization.chain_id.clone()?, + authorization.signer, + authorization.nonce? + )) + }); + ParsedEnvelope { + envelope, + signer, + nonce, + replay_key, + } +} + +fn tx_ids_from_value(value: &Value, authorized_by: Option) -> Result> { + let mut ids = Vec::new(); + for parsed in parsed_envelopes_from_value(value, authorized_by)? { + ids.push(parsed.envelope.tx_id); + } + Ok(ids) } fn authorization_from_value(value: Option<&Value>) -> Result> { @@ -933,6 +1330,361 @@ fn authorization_from_value(value: Option<&Value>) -> Result Result { + let envelope_value = value.get("envelope").unwrap_or(value); + let payload = envelope_value + .get("payload") + .or_else(|| value.get("payload")) + .or_else(|| value.get("document")) + .ok_or_else(|| anyhow!("signed transaction envelope must include payload or document"))?; + let tx_value = payload + .get("tx") + .or_else(|| value.get("tx")) + .ok_or_else(|| anyhow!("signed transaction envelope payload must include tx"))?; + let tx: Transaction = serde_json::from_value(tx_value.clone()) + .context("failed to parse signed transaction payload tx")?; + + let schema = required_str(envelope_value, "schema")?; + if schema != "flowchain.local_transaction_envelope.v0" { + return Err(anyhow!("unsupported signed envelope schema: {schema}")); + } + let envelope_id = required_str(envelope_value, "envelopeId")?; + let chain_id = required_str(envelope_value, "chainId")?; + let nonce = required_u64(envelope_value, "nonce")?; + let signer_id = envelope_signer_str(envelope_value, "signerId")?; + let signer_key_id = envelope_signer_str(envelope_value, "signerKeyId")?; + let signer_role = envelope_signer_str(envelope_value, "signerRole")?; + let signer_role_code = envelope_signer_u64(envelope_value, "signerRoleCode")?; + let public_key = envelope_signer_str(envelope_value, "publicKey")?; + let domain = required_str(envelope_value, "domain")?; + let domain_separator = required_str(envelope_value, "domainSeparator")?; + let payload_hash = required_str(envelope_value, "payloadHash")?; + let object_id = envelope_value.get("objectId").and_then(Value::as_str); + let object_type_hash = envelope_value.get("objectTypeHash").and_then(Value::as_str); + let issued_at_unix_ms = required_u64(envelope_value, "issuedAtUnixMs")?; + let signing_digest = required_str(envelope_value, "signingDigest")?; + let signature = required_str(envelope_value, "signature")?; + + let expected_payload_hash = canonical_json_hash(payload); + if payload_hash != expected_payload_hash { + return Err(anyhow!("bad-payload-hash")); + } + let expected_domain = + format!("flowchain.local-alpha.v0.local-transaction-envelope:chain:{chain_id}"); + let legacy_domain = "flowchain.local.v0.transaction-envelope".to_string(); + if domain != expected_domain && domain != legacy_domain { + return Err(anyhow!("wrong-domain")); + } + if domain_separator != keccak_hex(domain.as_bytes()) { + return Err(anyhow!("wrong-domain")); + } + let expected_role_code = match signer_role { + "operator" => 1, + "agent" => 2, + "verifier" => 3, + "hardware" => 4, + _ => return Err(anyhow!("wrong-signer")), + }; + if signer_role_code != expected_role_code { + return Err(anyhow!("wrong-signer")); + } + + if let (Some(object_id), Some(object_type_hash)) = (object_id, object_type_hash) { + let expected_envelope_id = + local_transaction_envelope_hash(LocalTransactionEnvelopeInput { + chain_id, + domain_separator, + signer_id, + signer_key_id, + signer_role: signer_role_code, + nonce, + payload_hash, + object_id, + object_type_hash, + issued_at_unix_ms, + })?; + if envelope_id != expected_envelope_id { + return Err(anyhow!("bad-envelope-id")); + } + let expected_digest = eip712_digest(domain_separator, &expected_envelope_id)?; + if signing_digest != expected_digest { + return Err(anyhow!("bad-envelope-digest")); + } + } + if !verify_signature(signing_digest, signature, public_key)? { + return Err(anyhow!("bad-signature")); + } + + let replay_key = format!("{chain_id}:{domain}:{signer_id}:{nonce}"); + let authorization = LocalAuthorization { + mode: "signed-local-transaction-envelope".to_string(), + signer: signer_id.to_string(), + digest: signing_digest.to_string(), + chain_id: Some(chain_id.to_string()), + nonce: Some(nonce), + signer_role: Some(signer_role.to_string()), + signer_key_id: Some(signer_key_id.to_string()), + public_key: Some(public_key.to_string()), + signature: Some(signature.to_string()), + replay_key: Some(replay_key.clone()), + }; + Ok(ParsedEnvelope { + envelope: crate::model::TxEnvelope { + tx_id: envelope_id.to_string(), + tx, + authorization: Some(authorization), + submitted_at_block: 0, + }, + signer: Some(signer_id.to_string()), + nonce: Some(nonce), + replay_key: Some(replay_key), + }) +} + +struct LocalTransactionEnvelopeInput<'a> { + chain_id: &'a str, + domain_separator: &'a str, + signer_id: &'a str, + signer_key_id: &'a str, + signer_role: u64, + nonce: u64, + payload_hash: &'a str, + object_id: &'a str, + object_type_hash: &'a str, + issued_at_unix_ms: u64, +} + +fn local_transaction_envelope_hash(input: LocalTransactionEnvelopeInput<'_>) -> Result { + let mut encoded = Vec::with_capacity(320); + encoded.extend(hex_32(&keccak_hex(LOCAL_TRANSACTION_TYPE.as_bytes()))?); + encoded.extend(uint_word(parse_u128(input.chain_id, "chainId")?)); + encoded.extend(hex_32(input.domain_separator)?); + encoded.extend(hex_32(input.signer_id)?); + encoded.extend(hex_32(input.signer_key_id)?); + encoded.extend(uint_word(input.signer_role as u128)); + encoded.extend(uint_word(input.nonce as u128)); + encoded.extend(hex_32(input.payload_hash)?); + encoded.extend(hex_32(input.object_id)?); + encoded.extend(hex_32(input.object_type_hash)?); + encoded.extend(uint_word(input.issued_at_unix_ms as u128)); + Ok(keccak_hex(&encoded)) +} + +fn eip712_digest(domain_separator: &str, struct_hash: &str) -> Result { + let mut encoded = Vec::with_capacity(66); + encoded.extend([0x19, 0x01]); + encoded.extend(hex_32(domain_separator)?); + encoded.extend(hex_32(struct_hash)?); + Ok(keccak_hex(&encoded)) +} + +fn verify_signature(digest: &str, signature: &str, public_key: &str) -> Result { + let digest_bytes = hex_32(digest)?; + let signature_bytes = hex_bytes(signature, 64)?; + let public_key_bytes = hex_bytes(public_key, 0)?; + let verifying_key = + VerifyingKey::from_sec1_bytes(&public_key_bytes).map_err(|_| anyhow!("bad-public-key"))?; + let signature = + Signature::from_slice(&signature_bytes).map_err(|_| anyhow!("bad-signature"))?; + Ok(verifying_key + .verify_prehash(&digest_bytes, &signature) + .is_ok()) +} + +fn hex_32(value: &str) -> Result> { + hex_bytes(value, 32) +} + +fn hex_bytes(value: &str, expected_len: usize) -> Result> { + let stripped = value + .strip_prefix("0x") + .ok_or_else(|| anyhow!("hex value must start with 0x"))?; + let bytes = hex::decode(stripped).map_err(|_| anyhow!("malformed hex"))?; + if expected_len > 0 && bytes.len() != expected_len { + return Err(anyhow!("hex value has wrong length")); + } + Ok(bytes) +} + +fn uint_word(value: u128) -> Vec { + let mut word = vec![0_u8; 32]; + word[16..].copy_from_slice(&value.to_be_bytes()); + word +} + +fn parse_u128(value: &str, field: &str) -> Result { + value + .parse::() + .with_context(|| format!("{field} must be an unsigned integer string")) +} + +fn required_str<'a>(value: &'a Value, key: &str) -> Result<&'a str> { + value + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("missing string field {key}")) +} + +fn required_u64(value: &Value, key: &str) -> Result { + let raw = value + .get(key) + .and_then(|value| value.as_str().or_else(|| value.as_u64().map(|_| ""))) + .ok_or_else(|| anyhow!("missing integer field {key}"))?; + if raw.is_empty() { + return value + .get(key) + .and_then(Value::as_u64) + .ok_or_else(|| anyhow!("missing integer field {key}")); + } + raw.parse::() + .with_context(|| format!("{key} must be an unsigned integer string")) +} + +fn envelope_signer_str<'a>(value: &'a Value, key: &str) -> Result<&'a str> { + value + .get(key) + .and_then(Value::as_str) + .or_else(|| { + value + .get("signer") + .and_then(|signer| signer.get(key)) + .and_then(Value::as_str) + }) + .ok_or_else(|| anyhow!("missing string field {key}")) +} + +fn envelope_signer_u64(value: &Value, key: &str) -> Result { + if value.get(key).is_some() { + return required_u64(value, key); + } + let signer = value + .get("signer") + .ok_or_else(|| anyhow!("missing signer object"))?; + required_u64(signer, key) +} + +fn preflight_state_with_pending(state: &crate::model::ChainState) -> crate::model::ChainState { + let mut simulation = state.clone(); + let pending = simulation.pending_txs.clone(); + simulation.pending_txs.clear(); + for envelope in pending { + let _ = crate::model::apply_transaction(&mut simulation, &envelope.tx); + } + simulation +} + +fn validate_and_queue_envelope( + state: &mut crate::model::ChainState, + simulation: &mut crate::model::ChainState, + mut parsed: ParsedEnvelope, + source: Option, +) -> std::result::Result { + parsed.envelope.submitted_at_block = state.next_block_number; + validate_envelope_for_mempool(state, simulation, &parsed).map_err(|reason| RejectedTx { + tx_id: Some(parsed.envelope.tx_id.clone()), + reason, + source: source.clone(), + })?; + let tx_id = parsed.envelope.tx_id.clone(); + if let Some(signer) = parsed.signer.clone() + && let Some(nonce) = parsed.nonce + { + state.account_nonces.insert( + signer.clone(), + crate::model::AccountNonce { + signer, + next_nonce: nonce + 1, + last_tx_id: Some(tx_id.clone()), + updated_at_block: state.next_block_number, + }, + ); + } + if let Some(replay_key) = parsed.replay_key.clone() + && let Some(signer) = parsed.signer.clone() + && let Some(nonce) = parsed.nonce + { + state.replay_keys.insert( + replay_key.clone(), + crate::model::ReplayKeyRecord { + replay_key, + tx_id: tx_id.clone(), + signer, + nonce, + accepted_at_block: state.next_block_number, + }, + ); + } + crate::model::apply_transaction(simulation, &parsed.envelope.tx).map_err(|error| { + RejectedTx { + tx_id: Some(tx_id.clone()), + reason: error.to_string(), + source, + } + })?; + record_pending_transaction(state, &parsed.envelope); + state.pending_txs.push(parsed.envelope); + Ok(tx_id) +} + +fn try_queue_envelope( + state: &mut crate::model::ChainState, + parsed: ParsedEnvelope, +) -> Result { + let mut simulation = preflight_state_with_pending(state); + validate_and_queue_envelope(state, &mut simulation, parsed, None) + .map_err(|rejected| anyhow!(rejected.reason)) +} + +fn validate_envelope_for_mempool( + state: &crate::model::ChainState, + simulation: &crate::model::ChainState, + parsed: &ParsedEnvelope, +) -> std::result::Result<(), String> { + if state.pending_txs.len() >= MAX_MEMPOOL_TXS { + return Err("mempool-full".to_string()); + } + if parsed.envelope.tx_id.trim().is_empty() { + return Err("missing-tx-id".to_string()); + } + if state + .pending_txs + .iter() + .any(|pending| pending.tx_id == parsed.envelope.tx_id) + || state.consumed_tx_ids.contains_key(&parsed.envelope.tx_id) + { + return Err("duplicate-tx-id".to_string()); + } + if let Some(authorization) = &parsed.envelope.authorization + && let Some(chain_id) = &authorization.chain_id + && chain_id != &state.chain_id + { + return Err("wrong-chain-id".to_string()); + } + if let Some(replay_key) = &parsed.replay_key + && state.replay_keys.contains_key(replay_key) + { + return Err("replay".to_string()); + } + if let Some(signer) = &parsed.signer + && let Some(nonce) = parsed.nonce + { + let expected = state + .account_nonces + .get(signer) + .map(|record| record.next_nonce) + .unwrap_or(1); + if nonce < expected { + return Err("stale-nonce".to_string()); + } + if nonce > expected { + return Err("future-nonce".to_string()); + } + } + crate::model::apply_transaction(&mut simulation.clone(), &parsed.envelope.tx) + .map_err(|error| error.to_string())?; + Ok(()) +} + struct DemoRun { state: crate::model::ChainState, first_block_hash: String, @@ -993,6 +1745,139 @@ fn build_product_smoke_state() -> DemoRun { } } +fn run_node_smoke( + state_path: &Path, + _node_dir: &Path, + out_dir: &Path, +) -> Result { + fs::create_dir_all(out_dir)?; + let mut state = genesis_state(); + let txs = production_runtime_smoke_transactions(); + let mut tx_ids = Vec::with_capacity(txs.len()); + for tx in txs { + tx_ids.push(queue_authorized_transaction( + &mut state, + tx, + "local-node-smoke-operator".to_string(), + )); + } + let mut block_hashes = Vec::new(); + while state.blocks.len() < 10 { + block_hashes.push(build_block(&mut state).block_hash); + } + save_state(state_path, &state)?; + write_runtime_boundary_files(state_path, &state)?; + let restart_state = load_state(state_path)?; + let snapshot_path = out_dir.join("state-snapshot.json"); + write_json(snapshot_path.clone(), &restart_state)?; + let imported_state: crate::model::ChainState = + serde_json::from_str(&fs::read_to_string(&snapshot_path)?)?; + let report = NodeRuntimeSmokeReport { + schema: "flowchain.private_testnet.production_node_smoke.v0".to_string(), + commands_run: vec!["flowmemory-devnet node-smoke".to_string()], + block_count: restart_state.blocks.len(), + tx_ids, + receipt_ids: restart_state.receipts.keys().cloned().collect(), + state_root: state_root(&restart_state), + latest_hash: restart_state.latest_hash.clone(), + restart_proof: RestartProof { + before_state_root: state_root(&state), + after_state_root: state_root(&restart_state), + before_latest_hash: state.latest_hash.clone(), + after_latest_hash: restart_state.latest_hash.clone(), + preserved: state_root(&state) == state_root(&restart_state) + && state.latest_hash == restart_state.latest_hash, + }, + export_import_proof: ExportImportProof { + imported_state_root: state_root(&imported_state), + preserved: state_root(&restart_state) == state_root(&imported_state), + }, + failure_details: Vec::new(), + block_hashes, + }; + write_json(out_dir.join("production-node-smoke-report.json"), &report)?; + Ok(report) +} + +fn production_runtime_smoke_transactions() -> Vec { + let mut txs = Vec::new(); + txs.extend(demo_transactions()); + txs.extend(product_demo_transactions()); + + let token_id = deterministic_token_id("FLOWT"); + let token_transfer_id = hash_json( + "flowmemory.local_devnet.token_transfer_id.v0", + &serde_json::json!({ + "tokenId": token_id, + "from": "local-account:product:alice", + "to": "local-account:product:bob", + "amount": 100_u64, + "memo": "node-smoke-token-transfer" + }), + ); + txs.push(Transaction::TransferLocalTestToken { + transfer_id: token_transfer_id, + token_id, + from_account_id: "local-account:product:alice".to_string(), + to_account_id: "local-account:product:bob".to_string(), + amount_units: 100, + memo: "node-smoke-token-transfer".to_string(), + }); + + txs.push(Transaction::CreateLocalTestUnitBalance { + account_id: "local-account:bridge:bob".to_string(), + owner: "operator:bridge:bob".to_string(), + }); + let replay_key = keccak_hex(b"flowchain.node-smoke.bridge.replay-key"); + let credit_id = hash_json( + "flowmemory.local_devnet.bridge_credit_id.v0", + &serde_json::json!({ + "replayKey": replay_key, + "recipient": "local-account:bridge:alice", + "amount": 75_u64 + }), + ); + txs.push(Transaction::ApplyBridgeCredit { + credit_id: credit_id.clone(), + observation_id: keccak_hex(b"flowchain.node-smoke.bridge.observation"), + deposit_id: keccak_hex(b"flowchain.node-smoke.bridge.deposit"), + replay_key: replay_key.clone(), + source_chain_id: 8453, + source_contract: "0x1111111111111111111111111111111111111111".to_string(), + source_tx_hash: keccak_hex(b"flowchain.node-smoke.bridge.source-tx"), + source_log_index: 0, + token: "0x3333333333333333333333333333333333333333".to_string(), + asset_id: LOCAL_TEST_UNIT_ASSET_ID.to_string(), + recipient_account_id: "local-account:bridge:alice".to_string(), + amount_units: 75, + verifier: "bridge-verifier:local-smoke".to_string(), + evidence_hash: keccak_hex(b"flowchain.node-smoke.bridge.evidence"), + local_only: true, + production_ready: false, + base_observed_at: "2026-05-13T00:00:00.000Z".to_string(), + handoff_written_at: "2026-05-13T00:00:01.000Z".to_string(), + node_ingested_at: "2026-05-13T00:00:02.000Z".to_string(), + }); + txs.push(Transaction::TransferLocalTestUnits { + transfer_id: "transfer:bridge:alice-to-bob".to_string(), + from_account_id: "local-account:bridge:alice".to_string(), + to_account_id: "local-account:bridge:bob".to_string(), + amount_units: 25, + memo: "bridge-credit-spend-proof".to_string(), + }); + txs.push(Transaction::RequestWithdrawal { + withdrawal_intent_id: keccak_hex(b"flowchain.node-smoke.withdrawal.intent"), + credit_id, + account_id: "local-account:bridge:alice".to_string(), + asset_id: LOCAL_TEST_UNIT_ASSET_ID.to_string(), + amount_units: 10, + destination_chain_id: 8453, + base_recipient: "0x4444444444444444444444444444444444444444".to_string(), + memo: "test-mode-withdrawal-intent".to_string(), + }); + txs +} + fn transactions_from_fixture(path: &Path) -> Result> { let body = fs::read_to_string(path) .with_context(|| format!("failed to read fixture {}", path.display()))?; @@ -1010,6 +1895,15 @@ fn transactions_from_fixture(path: &Path) -> Result> { return Ok(vec![tx]); } + if value + .get("schema") + .and_then(Value::as_str) + .is_some_and(|schema| schema == "flowmemory.bridge_runtime_handoff.v0") + { + return bridge_handoff_transactions(&value, false) + .with_context(|| format!("failed to parse bridge handoff {}", path.display())); + } + if value.get("rawLog").is_some() && value.get("expected").is_some() { return Ok(vec![Transaction::ImportFlowPulseObservation( observation_from_flowpulse_fixture(&value)?, @@ -1023,11 +1917,264 @@ fn transactions_from_fixture(path: &Path) -> Result> { } Err(anyhow!( - "unsupported fixture shape in {}: expected tx, txs, FlowPulse observation, or verifier report fixture", + "unsupported fixture shape in {}: expected tx, txs, bridge handoff, FlowPulse observation, or verifier report fixture", path.display() )) } +fn bridge_handoff_transactions_from_path( + path: &Path, + require_live: bool, +) -> Result> { + let body = fs::read_to_string(path) + .with_context(|| format!("failed to read bridge handoff {}", path.display()))?; + let value: Value = serde_json::from_str(body.trim_start_matches('\u{feff}')) + .with_context(|| format!("failed to parse bridge handoff {}", path.display()))?; + bridge_handoff_transactions(&value, require_live) +} + +fn bridge_handoff_transactions(value: &Value, require_live: bool) -> Result> { + let schema = value + .get("schema") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("bridge handoff missing schema"))?; + if schema != "flowmemory.bridge_runtime_handoff.v0" { + return Err(anyhow!("unsupported bridge handoff schema: {schema}")); + } + if require_live { + require_bool(value, "productionReady", true)?; + require_bool(value, "localOnly", false)?; + } + + let credits = value + .get("credits") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("bridge handoff missing credits array"))?; + let observations = value + .get("observations") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let handoff_written_at = string_value_optional(value, "handoffWrittenAt") + .or_else(|| string_value_optional(value, "generatedAt")) + .unwrap_or_else(current_rfc3339); + let mut txs = Vec::new(); + let mut local_accounts = BTreeSet::new(); + + for credit in credits { + let status = credit + .get("status") + .and_then(Value::as_str) + .unwrap_or("pending"); + if status == "rejected" { + continue; + } + if require_live { + require_bool(credit, "productionReady", true)?; + require_bool(credit, "localOnly", false)?; + } + + let source = credit + .get("source") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("bridge credit missing source object"))?; + let source_chain_id = value_to_u64( + source + .get("chainId") + .ok_or_else(|| anyhow!("bridge credit source missing chainId"))?, + "source.chainId", + )?; + if require_live && source_chain_id != 8453 { + return Err(anyhow!( + "live bridge handoff source chain must be Base 8453, got {source_chain_id}" + )); + } + let observation_id = string_value(credit, "observationId")?; + if require_live { + require_confirmation_eligible(&observations, &observation_id)?; + } + let flowchain_recipient = string_value(credit, "flowchainRecipient")?; + let recipient_account_id = string_value_optional(credit, "recipientAccountId") + .unwrap_or_else(|| deterministic_bridge_account_id(&flowchain_recipient)); + if local_accounts.insert(recipient_account_id.clone()) { + txs.push(Transaction::CreateLocalTestUnitBalance { + account_id: recipient_account_id.clone(), + owner: "operator:bridge:pilot".to_string(), + }); + } + + let node_ingested_at = current_rfc3339(); + let base_observed_at = string_value_optional(credit, "baseObservedAt") + .or_else(|| observation_timestamp(&observations, &observation_id)) + .or_else(|| string_value_optional(value, "baseObservedAt")) + .unwrap_or_else(|| handoff_written_at.clone()); + let source_contract = string_field(source, "contract")?; + let source_tx_hash = string_field(source, "txHash")?; + let source_log_index = value_to_u64( + source + .get("logIndex") + .ok_or_else(|| anyhow!("bridge credit source missing logIndex"))?, + "source.logIndex", + )?; + let credit_id = string_value(credit, "creditId")?; + txs.push(Transaction::ApplyBridgeCredit { + credit_id: credit_id.clone(), + observation_id, + deposit_id: string_value(credit, "depositId")?, + replay_key: string_value(credit, "replayKey").unwrap_or_else(|_| { + crate::model::bridge_source_replay_key( + source_chain_id, + &source_contract, + &source_tx_hash, + source_log_index, + ) + }), + source_chain_id, + source_contract, + source_tx_hash, + source_log_index, + token: string_value(credit, "token")?, + asset_id: LOCAL_TEST_UNIT_ASSET_ID.to_string(), + recipient_account_id, + amount_units: value_to_u64( + credit + .get("amount") + .ok_or_else(|| anyhow!("bridge credit missing amount"))?, + "amount", + )?, + verifier: "bridge-verifier:base8453-live-handoff".to_string(), + evidence_hash: string_value_optional(credit, "evidenceHash").unwrap_or_else(|| { + hash_json( + "flowmemory.local_devnet.bridge_credit_evidence.v0", + &normalize_value(credit.clone()), + ) + }), + local_only: bool_field_default(credit, "localOnly", true), + production_ready: bool_field_default(credit, "productionReady", false), + base_observed_at, + handoff_written_at: string_value_optional(credit, "handoffWrittenAt") + .unwrap_or_else(|| handoff_written_at.clone()), + node_ingested_at, + }); + } + + Ok(txs) +} + +fn deterministic_bridge_account_id(flowchain_recipient: &str) -> String { + format!( + "local-account:bridge:{}", + hash_json( + "flowmemory.local_devnet.bridge_account_id.v0", + &serde_json::json!({ "flowchainRecipient": flowchain_recipient }) + ) + .trim_start_matches("0x") + ) +} + +fn bridge_credit_id_from_tx(tx: &Transaction) -> Option { + match tx { + Transaction::ApplyBridgeCredit { credit_id, .. } => Some(credit_id.clone()), + _ => None, + } +} + +fn current_rfc3339() -> String { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs().to_string()) + .unwrap_or_else(|_| "0".to_string()) +} + +fn string_value(value: &Value, key: &str) -> Result { + value + .get(key) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow!("missing string field {key}")) +} + +fn string_value_optional(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} + +fn value_to_u64(value: &Value, label: &str) -> Result { + match value { + Value::String(value) => value + .parse::() + .with_context(|| format!("{label} must be an unsigned integer string")), + Value::Number(value) => value + .as_u64() + .ok_or_else(|| anyhow!("{label} must be a non-negative integer")), + _ => Err(anyhow!("{label} must be a string or number")), + } +} + +fn bool_field_default(value: &Value, key: &str, default: bool) -> bool { + value.get(key).and_then(Value::as_bool).unwrap_or(default) +} + +fn require_bool(value: &Value, key: &str, expected: bool) -> Result<()> { + let actual = value + .get(key) + .and_then(Value::as_bool) + .ok_or_else(|| anyhow!("live bridge handoff missing boolean field {key}"))?; + if actual != expected { + return Err(anyhow!( + "live bridge handoff field {key} must be {expected}, got {actual}" + )); + } + Ok(()) +} + +fn observation_timestamp(observations: &[Value], observation_id: &str) -> Option { + observations + .iter() + .find(|observation| { + observation + .get("observationId") + .and_then(Value::as_str) + .is_some_and(|id| id == observation_id) + }) + .and_then(|observation| observation.get("observedAt")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} + +fn require_confirmation_eligible(observations: &[Value], observation_id: &str) -> Result<()> { + let Some(observation) = observations.iter().find(|observation| { + observation + .get("observationId") + .and_then(Value::as_str) + .is_some_and(|id| id == observation_id) + }) else { + return Err(anyhow!( + "live bridge handoff missing observation {observation_id}" + )); + }; + let confirmation = observation + .get("guardrails") + .and_then(|guardrails| guardrails.get("confirmation")) + .ok_or_else(|| anyhow!("live bridge handoff missing confirmation evidence"))?; + let depth = confirmation + .get("depth") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow!("live bridge handoff confirmation depth is missing"))?; + let satisfied = confirmation + .get("satisfied") + .and_then(Value::as_bool) + .unwrap_or(false); + if depth < 12 || !satisfied { + return Err(anyhow!( + "live bridge handoff is not 12-confirmation eligible: depth={depth}, satisfied={satisfied}" + )); + } + Ok(()) +} + fn observation_from_flowpulse_fixture(value: &Value) -> Result { let raw = value .get("rawLog") @@ -1106,7 +2253,10 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "stateRoot": state_root(state), "mapRoots": map_roots, "blockHeight": state.blocks.len(), + "latestHash": state.latest_hash, + "finalizedHeight": state.finalized_height, "rootfields": state.rootfields, + "accountNonces": state.account_nonces, "agentAccounts": state.agent_accounts, "localTestUnitBalances": state.local_test_unit_balances, "faucetRecords": state.faucet_records, @@ -1114,6 +2264,7 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "tokenDefinitions": state.token_definitions, "tokenBalances": state.token_balances, "tokenMintReceipts": state.token_mint_receipts, + "tokenTransferReceipts": state.token_transfer_receipts, "dexPools": state.dex_pools, "lpPositions": state.lp_positions, "liquidityReceipts": state.liquidity_receipts, @@ -1127,6 +2278,14 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "verifierModules": state.verifier_modules, "workReceipts": state.work_receipts, "verifierReports": state.verifier_reports, + "bridgeObservations": state.bridge_observations, + "bridgeCredits": state.bridge_credits, + "bridgeCreditReceipts": state.bridge_credit_receipts, + "bridgeReplayKeys": state.bridge_replay_keys, + "withdrawalIntents": state.withdrawal_intents, + "transactions": state.transactions, + "receipts": state.receipts, + "events": state.events, "baseAnchors": state.base_anchors, }); @@ -1135,6 +2294,7 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "genesisConfig": state.config, "importedObservations": state.imported_observations, "operatorKeyReferences": state.operator_key_references, + "accountNonces": state.account_nonces, "agentAccounts": state.agent_accounts, "localTestUnitBalances": state.local_test_unit_balances, "faucetRecords": state.faucet_records, @@ -1142,6 +2302,7 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "tokenDefinitions": state.token_definitions, "tokenBalances": state.token_balances, "tokenMintReceipts": state.token_mint_receipts, + "tokenTransferReceipts": state.token_transfer_receipts, "dexPools": state.dex_pools, "lpPositions": state.lp_positions, "liquidityReceipts": state.liquidity_receipts, @@ -1150,6 +2311,14 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "challenges": state.challenges, "finalityReceipts": state.finality_receipts, "artifactAvailabilityProofs": state.artifact_availability_proofs, + "bridgeObservations": state.bridge_observations, + "bridgeCredits": state.bridge_credits, + "bridgeCreditReceipts": state.bridge_credit_receipts, + "bridgeReplayKeys": state.bridge_replay_keys, + "withdrawalIntents": state.withdrawal_intents, + "transactions": state.transactions, + "receipts": state.receipts, + "events": state.events, "blocks": state.blocks, "mapRoots": state_map_roots(state), "stateRoot": state_root(state), @@ -1159,12 +2328,14 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "schema": "flowmemory.verifier_handoff.local_devnet.v0", "genesisConfig": state.config, "operatorKeyReferences": state.operator_key_references, + "accountNonces": state.account_nonces, "localTestUnitBalances": state.local_test_unit_balances, "faucetRecords": state.faucet_records, "balanceTransfers": state.balance_transfers, "tokenDefinitions": state.token_definitions, "tokenBalances": state.token_balances, "tokenMintReceipts": state.token_mint_receipts, + "tokenTransferReceipts": state.token_transfer_receipts, "dexPools": state.dex_pools, "lpPositions": state.lp_positions, "liquidityReceipts": state.liquidity_receipts, @@ -1176,6 +2347,14 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "finalityReceipts": state.finality_receipts, "artifactAvailabilityProofs": state.artifact_availability_proofs, "importedVerifierReports": state.imported_verifier_reports, + "bridgeObservations": state.bridge_observations, + "bridgeCredits": state.bridge_credits, + "bridgeCreditReceipts": state.bridge_credit_receipts, + "bridgeReplayKeys": state.bridge_replay_keys, + "withdrawalIntents": state.withdrawal_intents, + "transactions": state.transactions, + "receipts": state.receipts, + "events": state.events, "mapRoots": state_map_roots(state), "stateRoot": state_root(state), }); @@ -1187,11 +2366,18 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "chainId": state.chain_id, "stateRoot": state_root(state), "mapRoots": state_map_roots(state), + "latestHeight": state.latest_height, + "latestHash": state.latest_hash, + "finalizedHeight": state.finalized_height, "latestBlock": state.blocks.last(), "blocks": state.blocks, "pendingTxs": state.pending_txs, + "transactions": state.transactions, + "receipts": state.receipts, + "events": state.events, "objects": { "rootfields": state.rootfields, + "accountNonces": state.account_nonces, "agentAccounts": state.agent_accounts, "localTestUnitBalances": state.local_test_unit_balances, "faucetRecords": state.faucet_records, @@ -1199,6 +2385,7 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "tokenDefinitions": state.token_definitions, "tokenBalances": state.token_balances, "tokenMintReceipts": state.token_mint_receipts, + "tokenTransferReceipts": state.token_transfer_receipts, "dexPools": state.dex_pools, "lpPositions": state.lp_positions, "liquidityReceipts": state.liquidity_receipts, @@ -1212,6 +2399,11 @@ fn export_handoff(state: &crate::model::ChainState, out_dir: &Path) -> Result<() "verifierModules": state.verifier_modules, "workReceipts": state.work_receipts, "verifierReports": state.verifier_reports, + "bridgeObservations": state.bridge_observations, + "bridgeCredits": state.bridge_credits, + "bridgeCreditReceipts": state.bridge_credit_receipts, + "bridgeReplayKeys": state.bridge_replay_keys, + "withdrawalIntents": state.withdrawal_intents, "baseAnchors": state.base_anchors } }); @@ -1236,6 +2428,32 @@ 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("runtime-handoff.json"), + &serde_json::json!({ + "schema": "flowmemory.local_devnet.runtime_handoff.v0", + "chainId": state.chain_id, + "latestHeight": state.latest_height, + "latestHash": state.latest_hash, + "finalizedHeight": state.finalized_height, + "stateRoot": state_root(state), + "mapRoots": state_map_roots(state), + "mempool": { + "pending": state.pending_txs.len(), + "maxSize": MAX_MEMPOOL_TXS, + "pendingTxs": state.pending_txs + }, + "transactions": state.transactions, + "receipts": state.receipts, + "events": state.events, + "bridgeCredits": state.bridge_credits, + "bridgeCreditReceipts": state.bridge_credit_receipts, + "withdrawalIntents": state.withdrawal_intents, + "controlPlanePreferredHandoff": out_dir.join("handoff").join("control-plane-handoff.json"), + "dashboardPreferredHandoff": out_dir.join("handoff").join("dashboard-state.json") + }), + )?; + export_handoff(state, &out_dir.join("handoff"))?; Ok(()) } @@ -1269,10 +2487,124 @@ fn string_at(values: &[Value], index: usize, label: &str) -> Result { .ok_or_else(|| anyhow!("missing string value {label}")) } -#[derive(Debug, Serialize)] +#[derive(Debug, Default, Serialize)] #[serde(rename_all = "camelCase")] struct QueuedTransactions { queued: Vec, + rejected: Vec, +} + +impl QueuedTransactions { + fn accepted_only(queued: Vec) -> Self { + Self { + queued, + rejected: Vec::new(), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct BridgeIngestSummary { + schema: String, + handoff: PathBuf, + direct: bool, + require_live: bool, + credit_ids: Vec, + queued: QueuedTransactions, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct MempoolSummary { + schema: String, + max_size: usize, + pending: usize, + pending_txs: Vec, +} + +impl MempoolSummary { + fn from_state(state: &crate::model::ChainState) -> Self { + Self { + schema: "flowmemory.local_devnet.mempool.v0".to_string(), + max_size: MAX_MEMPOOL_TXS, + pending: state.pending_txs.len(), + pending_txs: state.pending_txs.clone(), + } + } +} + +fn query_state(state: &crate::model::ChainState, kind: QueryKind, id: &str) -> Result { + let value = match kind { + QueryKind::Block => { + let block = if let Ok(height) = id.parse::() { + state + .blocks + .iter() + .find(|block| block.block_number == height) + } else { + state.blocks.iter().find(|block| block.block_hash == id) + }; + serde_json::json!({ + "schema": "flowmemory.local_devnet.query.block.v0", + "id": id, + "block": block + }) + } + QueryKind::Transaction => serde_json::json!({ + "schema": "flowmemory.local_devnet.query.transaction.v0", + "id": id, + "transaction": state.transactions.get(id), + "pending": state.pending_txs.iter().find(|tx| tx.tx_id == id) + }), + QueryKind::Receipt => serde_json::json!({ + "schema": "flowmemory.local_devnet.query.receipt.v0", + "id": id, + "receipt": state.receipts.get(id) + }), + QueryKind::Account => { + let token_balances = state + .token_balances + .values() + .filter(|balance| balance.account_id == id) + .collect::>(); + serde_json::json!({ + "schema": "flowmemory.local_devnet.query.account.v0", + "id": id, + "localTestUnitBalance": state.local_test_unit_balances.get(id), + "agentAccount": state.agent_accounts.get(id), + "tokenBalances": token_balances, + "nonce": state.account_nonces.get(id) + }) + } + QueryKind::Token => serde_json::json!({ + "schema": "flowmemory.local_devnet.query.token.v0", + "id": id, + "token": state.token_definitions.get(id), + "balances": state.token_balances.values().filter(|balance| balance.token_id == id).collect::>() + }), + QueryKind::Pool => serde_json::json!({ + "schema": "flowmemory.local_devnet.query.pool.v0", + "id": id, + "pool": state.dex_pools.get(id), + "lpPositions": state.lp_positions.values().filter(|position| position.pool_id == id).collect::>(), + "liquidityReceipts": state.liquidity_receipts.values().filter(|receipt| receipt.pool_id == id).collect::>(), + "swapReceipts": state.swap_receipts.values().filter(|receipt| receipt.pool_id == id).collect::>() + }), + QueryKind::BridgeCredit => serde_json::json!({ + "schema": "flowmemory.local_devnet.query.bridge_credit.v0", + "id": id, + "credit": state.bridge_credits.get(id), + "receipt": state.bridge_credit_receipts.get(id), + "replayKey": state.bridge_credits.get(id).and_then(|credit| state.bridge_replay_keys.get(&credit.replay_key)) + }), + QueryKind::FinalityReceipt => serde_json::json!({ + "schema": "flowmemory.local_devnet.query.finality_receipt.v0", + "id": id, + "finalityReceipt": state.finality_receipts.get(id) + }), + }; + Ok(value) } #[derive(Debug, Serialize, Deserialize)] @@ -1286,20 +2618,37 @@ struct NodeStatus { state_path: PathBuf, node_dir: PathBuf, block_height: usize, + latest_height: u64, next_block_number: u64, latest_block_hash: String, + finalized_height: u64, state_root: String, + receipt_root: String, + event_root: String, pending_txs: usize, + max_mempool_txs: usize, + account_nonces: usize, + consumed_txs: usize, local_test_unit_balances: usize, faucet_records: usize, balance_transfers: usize, token_definitions: usize, token_balances: usize, token_mint_receipts: usize, + token_transfer_receipts: usize, dex_pools: usize, lp_positions: usize, liquidity_receipts: usize, swap_receipts: usize, + bridge_observations: usize, + bridge_credits: usize, + bridge_credit_receipts: usize, + bridge_replay_keys: usize, + withdrawal_intents: usize, + receipts: usize, + events: usize, + log_path: PathBuf, + last_error: Option, static_peer_sync: Option, last_ingested_txs: usize, last_rejected_inbox_files: usize, @@ -1328,24 +2677,41 @@ impl NodeStatus { state_path: state_path.to_path_buf(), node_dir: node_dir.to_path_buf(), block_height: state.blocks.len(), + latest_height: state.latest_height, next_block_number: state.next_block_number, latest_block_hash: state .blocks .last() .map(|block| block.block_hash.clone()) .unwrap_or_else(|| state.parent_hash.clone()), + finalized_height: state.finalized_height, state_root: state_root(state), + receipt_root: state_map_roots(state).receipt_root, + event_root: state_map_roots(state).event_root, pending_txs: state.pending_txs.len(), + max_mempool_txs: MAX_MEMPOOL_TXS, + account_nonces: state.account_nonces.len(), + consumed_txs: state.consumed_tx_ids.len(), local_test_unit_balances: state.local_test_unit_balances.len(), faucet_records: state.faucet_records.len(), balance_transfers: state.balance_transfers.len(), token_definitions: state.token_definitions.len(), token_balances: state.token_balances.len(), token_mint_receipts: state.token_mint_receipts.len(), + token_transfer_receipts: state.token_transfer_receipts.len(), dex_pools: state.dex_pools.len(), lp_positions: state.lp_positions.len(), liquidity_receipts: state.liquidity_receipts.len(), swap_receipts: state.swap_receipts.len(), + bridge_observations: state.bridge_observations.len(), + bridge_credits: state.bridge_credits.len(), + bridge_credit_receipts: state.bridge_credit_receipts.len(), + bridge_replay_keys: state.bridge_replay_keys.len(), + withdrawal_intents: state.withdrawal_intents.len(), + receipts: state.receipts.len(), + events: state.events.len(), + log_path: node_dir.join("node.log.jsonl"), + last_error: None, static_peer_sync, last_ingested_txs, last_rejected_inbox_files, @@ -1416,12 +2782,18 @@ impl NodeTickSummary { struct StateSummary { schema: String, chain_id: String, + latest_height: u64, next_block_number: u64, logical_time: u64, parent_hash: String, + latest_hash: String, + finalized_height: u64, state_root: String, map_roots: crate::model::StateMapRoots, operator_key_references: usize, + account_nonces: usize, + consumed_txs: usize, + replay_keys: usize, pending_txs: usize, blocks: usize, rootfields: usize, @@ -1433,6 +2805,7 @@ struct StateSummary { token_definitions: usize, token_balances: usize, token_mint_receipts: usize, + token_transfer_receipts: usize, dex_pools: usize, lp_positions: usize, liquidity_receipts: usize, @@ -1448,7 +2821,15 @@ struct StateSummary { verifier_reports: usize, imported_observations: usize, imported_verifier_reports: usize, + bridge_observations: usize, + bridge_credits: usize, + bridge_credit_receipts: usize, + bridge_replay_keys: usize, + withdrawal_intents: usize, base_anchors: usize, + transactions: usize, + receipts: usize, + events: usize, } impl StateSummary { @@ -1456,12 +2837,18 @@ impl StateSummary { Self { schema: "flowmemory.local_devnet.summary.v0".to_string(), chain_id: state.chain_id.clone(), + latest_height: state.latest_height, next_block_number: state.next_block_number, logical_time: state.logical_time, parent_hash: state.parent_hash.clone(), + latest_hash: state.latest_hash.clone(), + finalized_height: state.finalized_height, state_root: state_root(state), map_roots: state_map_roots(state), operator_key_references: state.operator_key_references.len(), + account_nonces: state.account_nonces.len(), + consumed_txs: state.consumed_tx_ids.len(), + replay_keys: state.replay_keys.len(), pending_txs: state.pending_txs.len(), blocks: state.blocks.len(), rootfields: state.rootfields.len(), @@ -1473,6 +2860,7 @@ impl StateSummary { token_definitions: state.token_definitions.len(), token_balances: state.token_balances.len(), token_mint_receipts: state.token_mint_receipts.len(), + token_transfer_receipts: state.token_transfer_receipts.len(), dex_pools: state.dex_pools.len(), lp_positions: state.lp_positions.len(), liquidity_receipts: state.liquidity_receipts.len(), @@ -1488,7 +2876,15 @@ impl StateSummary { verifier_reports: state.verifier_reports.len(), imported_observations: state.imported_observations.len(), imported_verifier_reports: state.imported_verifier_reports.len(), + bridge_observations: state.bridge_observations.len(), + bridge_credits: state.bridge_credits.len(), + bridge_credit_receipts: state.bridge_credit_receipts.len(), + bridge_replay_keys: state.bridge_replay_keys.len(), + withdrawal_intents: state.withdrawal_intents.len(), base_anchors: state.base_anchors.len(), + transactions: state.transactions.len(), + receipts: state.receipts.len(), + events: state.events.len(), } } } @@ -1782,6 +3178,39 @@ struct ProductSmokeChecks { base_anchor_created: bool, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct NodeRuntimeSmokeReport { + schema: String, + commands_run: Vec, + block_count: usize, + tx_ids: Vec, + receipt_ids: Vec, + state_root: String, + latest_hash: String, + restart_proof: RestartProof, + export_import_proof: ExportImportProof, + failure_details: Vec, + block_hashes: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct RestartProof { + before_state_root: String, + after_state_root: String, + before_latest_hash: String, + after_latest_hash: String, + preserved: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ExportImportProof { + imported_state_root: String, + preserved: bool, +} + impl ProductSmokeSummary { fn from_demo( state_path: PathBuf, diff --git a/crates/flowmemory-devnet/src/hash.rs b/crates/flowmemory-devnet/src/hash.rs index dbc5da75..ec6a4045 100644 --- a/crates/flowmemory-devnet/src/hash.rs +++ b/crates/flowmemory-devnet/src/hash.rs @@ -13,6 +13,11 @@ pub fn hash_json(domain: &str, value: &T) -> String { keccak_hex(format!("{domain}:{canonical}").as_bytes()) } +pub fn canonical_json_hash(value: &T) -> String { + let canonical = canonical_json(value); + keccak_hex(canonical.as_bytes()) +} + pub fn canonical_json(value: &T) -> String { let value = serde_json::to_value(value).expect("serializable value"); let normalized = normalize_value(value); diff --git a/crates/flowmemory-devnet/src/lib.rs b/crates/flowmemory-devnet/src/lib.rs index e2833206..4fcdb29f 100644 --- a/crates/flowmemory-devnet/src/lib.rs +++ b/crates/flowmemory-devnet/src/lib.rs @@ -1,3 +1,5 @@ +#![recursion_limit = "256"] + pub mod cli; pub mod hash; pub mod model; @@ -6,15 +8,17 @@ 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, - 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, + AccountNonce, AgentAccount, ArtifactAvailabilityProof, BalanceTransfer, BaseAnchorPlaceholder, + Block, BlockEvent, BlockReceipt, BridgeCredit, BridgeObservation, BridgeReplayKey, ChainState, + Challenge, ConsumedTx, DevnetConfig, DevnetError, DexPool, FaucetRecord, FinalityReceipt, + ImportedFlowPulseObservation, ImportedVerifierReport, LOCAL_TEST_UNIT_ASSET_ID, + LiquidityReceipt, LocalAuthorization, LocalTestToken, LocalTestTokenBalance, + LocalTestTokenMintReceipt, LocalTestTokenTransferReceipt, LocalTestUnitBalance, LpPosition, + MemoryCell, ModelPassport, OperatorKeyReference, ReplayKeyRecord, StateMapRoots, StoredReceipt, + StoredTransaction, SwapReceipt, Transaction, TxEnvelope, VerifierModule, WithdrawalIntent, + 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, record_pending_transaction, state_map_roots, state_root, }; diff --git a/crates/flowmemory-devnet/src/model.rs b/crates/flowmemory-devnet/src/model.rs index 86162690..05eec6b6 100644 --- a/crates/flowmemory-devnet/src/model.rs +++ b/crates/flowmemory-devnet/src/model.rs @@ -1,6 +1,8 @@ use crate::hash::{hash_json, keccak_hex}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::collections::BTreeMap; +use std::time::{SystemTime, UNIX_EPOCH}; use thiserror::Error; pub const STATE_SCHEMA: &str = "flowmemory.local_devnet.state.v0"; @@ -56,6 +58,8 @@ pub enum DevnetError { TokenBalanceInsufficient(String), #[error("token mint already exists: {0}")] TokenMintAlreadyExists(String), + #[error("token transfer already exists: {0}")] + TokenTransferAlreadyExists(String), #[error("pool already exists: {0}")] PoolAlreadyExists(String), #[error("pool does not exist: {0}")] @@ -138,6 +142,22 @@ pub enum DevnetError { AnchorAlreadyExists(String), #[error("invalid event signature: {0}")] InvalidEventSignature(String), + #[error("bridge source chain must be Base mainnet 8453: {0}")] + BridgeWrongSourceChain(u64), + #[error("bridge credit amount must be greater than zero: {0}")] + BridgeCreditAmountMustBePositive(String), + #[error("bridge replay key already consumed: {0}")] + BridgeReplayAlreadyConsumed(String), + #[error("bridge credit already exists: {0}")] + BridgeCreditAlreadyExists(String), + #[error("bridge source event already consumed: {0}")] + BridgeSourceEventAlreadyConsumed(String), + #[error("bridge credit does not exist: {0}")] + BridgeCreditMissing(String), + #[error("bridge withdrawal intent already exists: {0}")] + BridgeWithdrawalAlreadyExists(String), + #[error("bridge withdrawal amount must be greater than zero: {0}")] + BridgeWithdrawalAmountMustBePositive(String), } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -151,8 +171,20 @@ pub struct ChainState { pub next_block_number: u64, pub logical_time: u64, pub parent_hash: String, + #[serde(default)] + pub latest_height: u64, + #[serde(default)] + pub latest_hash: String, + #[serde(default)] + pub finalized_height: u64, #[serde(default = "default_operator_key_references")] pub operator_key_references: BTreeMap, + #[serde(default)] + pub account_nonces: BTreeMap, + #[serde(default)] + pub consumed_tx_ids: BTreeMap, + #[serde(default)] + pub replay_keys: BTreeMap, pub rootfields: BTreeMap, #[serde(default)] pub agent_accounts: BTreeMap, @@ -169,6 +201,8 @@ pub struct ChainState { #[serde(default)] pub token_mint_receipts: BTreeMap, #[serde(default)] + pub token_transfer_receipts: BTreeMap, + #[serde(default)] pub dex_pools: BTreeMap, #[serde(default)] pub lp_positions: BTreeMap, @@ -193,7 +227,23 @@ pub struct ChainState { pub verifier_reports: BTreeMap, pub imported_observations: BTreeMap, pub imported_verifier_reports: BTreeMap, + #[serde(default)] + pub bridge_observations: BTreeMap, + #[serde(default)] + pub bridge_credits: BTreeMap, + #[serde(default)] + pub bridge_credit_receipts: BTreeMap, + #[serde(default)] + pub bridge_replay_keys: BTreeMap, + #[serde(default)] + pub withdrawal_intents: BTreeMap, pub base_anchors: BTreeMap, + #[serde(default)] + pub transactions: BTreeMap, + #[serde(default)] + pub receipts: BTreeMap, + #[serde(default)] + pub events: BTreeMap, pub blocks: Vec, pub pending_txs: Vec, } @@ -204,10 +254,22 @@ pub struct DevnetConfig { pub schema: String, pub chain_id: String, pub network_id: String, + #[serde(default)] + pub network_profile: String, + #[serde(default)] + pub genesis_path: String, + #[serde(default)] + pub data_directory: String, pub genesis_hash: String, pub genesis_logical_time: u64, pub block_time_seconds: u64, + #[serde(default)] + pub block_interval_ms: u64, pub operator_key_reference_id: String, + #[serde(default)] + pub validator_identity_ref: String, + #[serde(default)] + pub peer_config_path: Option, pub no_value: bool, pub consensus: String, pub crypto_schema_refs: Vec, @@ -228,6 +290,34 @@ pub struct OperatorKeyReference { pub crypto_schema_refs: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AccountNonce { + pub signer: String, + pub next_nonce: u64, + pub last_tx_id: Option, + pub updated_at_block: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConsumedTx { + pub tx_id: String, + pub status: String, + pub first_seen_at_block: u64, + pub included_at_block: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ReplayKeyRecord { + pub replay_key: String, + pub tx_id: String, + pub signer: String, + pub nonce: u64, + pub accepted_at_block: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Rootfield { @@ -324,6 +414,19 @@ pub struct LocalTestTokenMintReceipt { pub no_value: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LocalTestTokenTransferReceipt { + pub transfer_id: String, + pub token_id: String, + pub from_account_id: String, + pub to_account_id: String, + pub amount_units: u64, + pub memo: String, + pub transferred_at_block: u64, + pub no_value: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct DexPool { @@ -529,6 +632,134 @@ pub struct ImportedVerifierReport { pub source: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BridgeObservation { + pub observation_id: String, + pub replay_key: String, + pub source_chain_id: u64, + pub source_contract: String, + pub source_tx_hash: String, + pub source_log_index: u64, + pub deposit_id: String, + pub token: String, + pub asset_id: String, + pub amount_units: u64, + pub flowchain_recipient: String, + pub status: String, + pub observed_at_block: u64, + pub local_only: bool, + pub production_ready: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BridgeCredit { + pub credit_id: String, + #[serde(default)] + pub receipt_id: String, + pub observation_id: String, + pub deposit_id: String, + pub replay_key: String, + pub source_chain_id: u64, + pub source_contract: String, + pub source_tx_hash: String, + pub source_log_index: u64, + pub asset_id: String, + #[serde(default)] + pub token: String, + pub recipient_account_id: String, + pub amount_units: u64, + pub verifier: String, + pub evidence_hash: String, + pub status: String, + pub credited_at_block: u64, + pub local_only: bool, + pub production_ready: bool, + #[serde(default)] + pub base_observed_at: String, + #[serde(default)] + pub handoff_written_at: String, + #[serde(default)] + pub node_ingested_at: String, + #[serde(default)] + pub credit_applied_at: String, + #[serde(default)] + pub first_spendable_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BridgeLatencyMeasurement { + pub base_observed_at: String, + pub handoff_written_at: String, + pub node_ingested_at: String, + pub credit_applied_at: String, + pub first_spendable_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_seconds: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BridgeCreditReceipt { + pub receipt_id: String, + pub credit_id: String, + pub observation_id: String, + pub deposit_id: String, + pub replay_key: String, + pub source_chain_id: u64, + pub source_contract: String, + pub source_tx_hash: String, + pub source_log_index: u64, + pub asset_id: String, + pub token: String, + pub recipient_account_id: String, + pub amount_units: u64, + pub verifier: String, + pub evidence_hash: String, + pub status: String, + pub credited_at_block: u64, + pub local_only: bool, + pub production_ready: bool, + pub latency: BridgeLatencyMeasurement, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BridgeReplayKey { + pub replay_key: String, + pub credit_id: String, + pub source_chain_id: u64, + #[serde(default)] + pub source_contract: String, + #[serde(default)] + pub source_tx_hash: String, + #[serde(default)] + pub source_log_index: u64, + #[serde(default)] + pub event_replay_key: String, + pub consumed_at_block: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WithdrawalIntent { + pub withdrawal_intent_id: String, + pub credit_id: String, + pub account_id: String, + pub asset_id: String, + pub amount_units: u64, + pub destination_chain_id: u64, + pub base_recipient: String, + pub status: String, + pub requested_at_block: u64, + pub memo: String, + pub test_mode: bool, + pub broadcast: bool, + pub production_ready: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct BaseAnchorPlaceholder { @@ -633,6 +864,14 @@ pub enum Transaction { amount_units: u64, reason: String, }, + TransferLocalTestToken { + transfer_id: String, + token_id: String, + from_account_id: String, + to_account_id: String, + amount_units: u64, + memo: String, + }, CreatePool { pool_id: String, base_asset_id: String, @@ -745,6 +984,42 @@ pub enum Transaction { appchain_chain_id: String, finality_status: String, }, + ApplyBridgeCredit { + credit_id: String, + observation_id: String, + deposit_id: String, + replay_key: String, + source_chain_id: u64, + source_contract: String, + source_tx_hash: String, + source_log_index: u64, + token: String, + asset_id: String, + recipient_account_id: String, + amount_units: u64, + verifier: String, + evidence_hash: String, + #[serde(default = "default_true")] + local_only: bool, + #[serde(default)] + production_ready: bool, + #[serde(default)] + base_observed_at: String, + #[serde(default)] + handoff_written_at: String, + #[serde(default)] + node_ingested_at: String, + }, + RequestWithdrawal { + withdrawal_intent_id: String, + credit_id: String, + account_id: String, + asset_id: String, + amount_units: u64, + destination_chain_id: u64, + base_recipient: String, + memo: String, + }, ImportFlowPulseObservation(ImportedFlowPulseObservation), ImportVerifierReport(ImportedVerifierReport), } @@ -756,6 +1031,8 @@ pub struct TxEnvelope { pub tx: Transaction, #[serde(default, skip_serializing_if = "Option::is_none")] pub authorization: Option, + #[serde(default)] + pub submitted_at_block: u64, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -764,6 +1041,20 @@ pub struct LocalAuthorization { pub mode: String, pub signer: String, pub digest: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chain_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nonce: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signer_role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signer_key_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub public_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub replay_key: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -775,7 +1066,15 @@ pub struct Block { pub logical_time: u64, pub tx_ids: Vec, pub receipts: Vec, + #[serde(default)] + pub events: Vec, pub state_root: String, + #[serde(default)] + pub receipt_root: String, + #[serde(default)] + pub event_root: String, + #[serde(default)] + pub finalized_height: u64, pub block_hash: String, } @@ -785,10 +1084,51 @@ pub struct BlockReceipt { pub tx_id: String, pub status: String, pub error: Option, + #[serde(default)] + pub block_number: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub event_ids: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub authorization: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BlockEvent { + pub event_id: String, + pub tx_id: String, + pub block_number: u64, + pub event_type: String, + pub object_id: String, + pub status: String, + pub payload: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StoredTransaction { + pub tx_id: String, + pub tx: Transaction, + pub authorization: Option, + pub status: String, + pub submitted_at_block: u64, + pub included_at_block: Option, + pub receipt_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StoredReceipt { + pub receipt_id: String, + pub tx_id: String, + pub block_number: u64, + pub status: String, + pub error: Option, + pub event_ids: Vec, + pub state_root: String, + pub authorization: Option, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct StateCommitmentView<'a> { @@ -805,6 +1145,7 @@ struct StateCommitmentView<'a> { token_definitions: &'a BTreeMap, token_balances: &'a BTreeMap, token_mint_receipts: &'a BTreeMap, + token_transfer_receipts: &'a BTreeMap, dex_pools: &'a BTreeMap, lp_positions: &'a BTreeMap, liquidity_receipts: &'a BTreeMap, @@ -820,6 +1161,11 @@ struct StateCommitmentView<'a> { verifier_reports: &'a BTreeMap, imported_observations: &'a BTreeMap, imported_verifier_reports: &'a BTreeMap, + bridge_observations: &'a BTreeMap, + bridge_credits: &'a BTreeMap, + bridge_credit_receipts: &'a BTreeMap, + bridge_replay_keys: &'a BTreeMap, + withdrawal_intents: &'a BTreeMap, base_anchors: &'a BTreeMap, } @@ -833,6 +1179,9 @@ struct RootMapView<'a, T> { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct StateMapRoots { + pub account_nonce_root: String, + pub consumed_tx_root: String, + pub replay_key_root: String, pub operator_key_reference_root: String, pub rootfield_state_root: String, pub agent_account_root: String, @@ -842,6 +1191,7 @@ pub struct StateMapRoots { pub token_definition_root: String, pub token_balance_root: String, pub token_mint_receipt_root: String, + pub token_transfer_receipt_root: String, pub dex_pool_root: String, pub lp_position_root: String, pub liquidity_receipt_root: String, @@ -857,18 +1207,32 @@ pub struct StateMapRoots { pub verifier_report_root: String, pub imported_observation_root: String, pub imported_verifier_report_root: String, + pub bridge_observation_root: String, + pub bridge_credit_root: String, + pub bridge_credit_receipt_root: String, + pub bridge_replay_key_root: String, + pub withdrawal_intent_root: String, pub base_anchor_root: String, + pub transaction_root: String, + pub receipt_root: String, + pub event_root: String, } pub fn default_config() -> DevnetConfig { DevnetConfig { schema: CONFIG_SCHEMA.to_string(), - chain_id: "flowmemory-local-devnet-v0".to_string(), - network_id: "flowmemory-private-local".to_string(), + chain_id: "31337".to_string(), + network_id: "flowchain-private-local".to_string(), + network_profile: "single-node-private-local-l1".to_string(), + genesis_path: "devnet/local/genesis-config.json".to_string(), + data_directory: "devnet/local".to_string(), genesis_hash: GENESIS_HASH.to_string(), genesis_logical_time: 1_778_688_000, block_time_seconds: 1, + block_interval_ms: 1_000, operator_key_reference_id: "operator-key:local-devnet:alpha".to_string(), + validator_identity_ref: "operator-key:local-devnet:alpha".to_string(), + peer_config_path: Some("devnet/local/node/peers.json".to_string()), no_value: true, consensus: "single-process deterministic local block production".to_string(), crypto_schema_refs: vec![ @@ -878,6 +1242,10 @@ pub fn default_config() -> DevnetConfig { } } +fn default_true() -> bool { + true +} + pub fn default_operator_key_references() -> BTreeMap { let reference = OperatorKeyReference { schema: OPERATOR_KEY_REFERENCE_SCHEMA.to_string(), @@ -908,8 +1276,14 @@ pub fn genesis_state() -> ChainState { next_block_number: 1, logical_time: config.genesis_logical_time, parent_hash: config.genesis_hash.clone(), + latest_height: 0, + latest_hash: config.genesis_hash.clone(), + finalized_height: 0, config, operator_key_references: default_operator_key_references(), + account_nonces: BTreeMap::new(), + consumed_tx_ids: BTreeMap::new(), + replay_keys: BTreeMap::new(), rootfields: BTreeMap::new(), agent_accounts: BTreeMap::new(), local_test_unit_balances: BTreeMap::new(), @@ -918,6 +1292,7 @@ pub fn genesis_state() -> ChainState { token_definitions: BTreeMap::new(), token_balances: BTreeMap::new(), token_mint_receipts: BTreeMap::new(), + token_transfer_receipts: BTreeMap::new(), dex_pools: BTreeMap::new(), lp_positions: BTreeMap::new(), liquidity_receipts: BTreeMap::new(), @@ -933,7 +1308,15 @@ pub fn genesis_state() -> ChainState { verifier_reports: BTreeMap::new(), imported_observations: BTreeMap::new(), imported_verifier_reports: BTreeMap::new(), + bridge_observations: BTreeMap::new(), + bridge_credits: BTreeMap::new(), + bridge_credit_receipts: BTreeMap::new(), + bridge_replay_keys: BTreeMap::new(), + withdrawal_intents: BTreeMap::new(), base_anchors: BTreeMap::new(), + transactions: BTreeMap::new(), + receipts: BTreeMap::new(), + events: BTreeMap::new(), blocks: Vec::new(), pending_txs: Vec::new(), } @@ -945,12 +1328,15 @@ pub fn envelope_tx(tx: Transaction) -> TxEnvelope { tx_id, tx, authorization: None, + submitted_at_block: 0, } } pub fn queue_transaction(state: &mut ChainState, tx: Transaction) -> String { - let envelope = envelope_tx(tx); + let mut envelope = envelope_tx(tx); + envelope.submitted_at_block = state.next_block_number; let tx_id = envelope.tx_id.clone(); + record_pending_transaction(state, &envelope); state.pending_txs.push(envelope); tx_id } @@ -965,12 +1351,45 @@ pub fn queue_authorized_transaction( mode: "local-authorized".to_string(), signer, digest: envelope.tx_id.clone(), + chain_id: None, + nonce: None, + signer_role: None, + signer_key_id: None, + public_key: None, + signature: None, + replay_key: None, }); + envelope.submitted_at_block = state.next_block_number; let tx_id = envelope.tx_id.clone(); + record_pending_transaction(state, &envelope); state.pending_txs.push(envelope); tx_id } +pub fn record_pending_transaction(state: &mut ChainState, envelope: &TxEnvelope) { + state + .consumed_tx_ids + .entry(envelope.tx_id.clone()) + .or_insert_with(|| ConsumedTx { + tx_id: envelope.tx_id.clone(), + status: "pending".to_string(), + first_seen_at_block: state.next_block_number, + included_at_block: None, + }); + state + .transactions + .entry(envelope.tx_id.clone()) + .or_insert_with(|| StoredTransaction { + tx_id: envelope.tx_id.clone(), + tx: envelope.tx.clone(), + authorization: envelope.authorization.clone(), + status: "pending".to_string(), + submitted_at_block: envelope.submitted_at_block, + included_at_block: None, + receipt_id: None, + }); +} + pub fn normalize_token_symbol(symbol: &str) -> String { symbol.trim().to_ascii_uppercase() } @@ -1021,6 +1440,44 @@ pub fn deterministic_pool_id(base_asset_id: &str, quote_asset_id: &str) -> Strin ) } +pub fn bridge_source_replay_key( + source_chain_id: u64, + source_contract: &str, + source_tx_hash: &str, + source_log_index: u64, +) -> String { + hash_json( + "flowmemory.local_devnet.bridge_source_replay_key.v0", + &serde_json::json!({ + "sourceChainId": source_chain_id, + "sourceContract": source_contract.to_ascii_lowercase(), + "txHash": source_tx_hash.to_ascii_lowercase(), + "logIndex": source_log_index + }), + ) +} + +fn now_rfc3339() -> String { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs().to_string()) + .unwrap_or_else(|_| "0".to_string()) +} + +fn timestamp_or_now(value: &str) -> String { + if value.trim().is_empty() { + now_rfc3339() + } else { + value.to_string() + } +} + +fn seconds_between(start: &str, end: &str) -> Option { + let start = start.parse::().ok()?; + let end = end.parse::().ok()?; + end.checked_sub(start) +} + pub fn deterministic_lp_position_id(pool_id: &str, owner_account_id: &str) -> String { hash_json( "flowmemory.local_devnet.lp_position_id.v0", @@ -1082,6 +1539,7 @@ pub fn state_root(state: &ChainState) -> String { token_definitions: &state.token_definitions, token_balances: &state.token_balances, token_mint_receipts: &state.token_mint_receipts, + token_transfer_receipts: &state.token_transfer_receipts, dex_pools: &state.dex_pools, lp_positions: &state.lp_positions, liquidity_receipts: &state.liquidity_receipts, @@ -1097,6 +1555,11 @@ pub fn state_root(state: &ChainState) -> String { verifier_reports: &state.verifier_reports, imported_observations: &state.imported_observations, imported_verifier_reports: &state.imported_verifier_reports, + bridge_observations: &state.bridge_observations, + bridge_credits: &state.bridge_credits, + bridge_credit_receipts: &state.bridge_credit_receipts, + bridge_replay_keys: &state.bridge_replay_keys, + withdrawal_intents: &state.withdrawal_intents, base_anchors: &state.base_anchors, }; hash_json("flowmemory.local_devnet.state_root.v0", &view) @@ -1111,6 +1574,15 @@ pub fn map_root(schema: &'static str, entries: &BTreeMap StateMapRoots { StateMapRoots { + account_nonce_root: map_root( + "flowmemory.local_devnet.account_nonces.v0", + &state.account_nonces, + ), + consumed_tx_root: map_root( + "flowmemory.local_devnet.consumed_txs.v0", + &state.consumed_tx_ids, + ), + replay_key_root: map_root("flowmemory.local_devnet.replay_keys.v0", &state.replay_keys), operator_key_reference_root: map_root( "flowmemory.local_devnet.operator_key_references.v0", &state.operator_key_references, @@ -1144,6 +1616,10 @@ pub fn state_map_roots(state: &ChainState) -> StateMapRoots { "flowmemory.local_devnet.token_mint_receipts.v0", &state.token_mint_receipts, ), + token_transfer_receipt_root: map_root( + "flowmemory.local_devnet.token_transfer_receipts.v0", + &state.token_transfer_receipts, + ), dex_pool_root: map_root("flowmemory.local_devnet.dex_pools.v0", &state.dex_pools), lp_position_root: map_root( "flowmemory.local_devnet.lp_positions.v0", @@ -1198,39 +1674,130 @@ pub fn state_map_roots(state: &ChainState) -> StateMapRoots { "flowmemory.local_devnet.imported_verifier_reports.v0", &state.imported_verifier_reports, ), + bridge_observation_root: map_root( + "flowmemory.local_devnet.bridge_observations.v0", + &state.bridge_observations, + ), + bridge_credit_root: map_root( + "flowmemory.local_devnet.bridge_credits.v0", + &state.bridge_credits, + ), + bridge_credit_receipt_root: map_root( + "flowmemory.local_devnet.bridge_credit_receipts.v0", + &state.bridge_credit_receipts, + ), + bridge_replay_key_root: map_root( + "flowmemory.local_devnet.bridge_replay_keys.v0", + &state.bridge_replay_keys, + ), + withdrawal_intent_root: map_root( + "flowmemory.local_devnet.withdrawal_intents.v0", + &state.withdrawal_intents, + ), base_anchor_root: map_root( "flowmemory.local_devnet.base_anchors.v0", &state.base_anchors, ), + transaction_root: map_root( + "flowmemory.local_devnet.transactions.v0", + &state.transactions, + ), + receipt_root: map_root("flowmemory.local_devnet.receipts.v0", &state.receipts), + event_root: map_root("flowmemory.local_devnet.events.v0", &state.events), } } 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 events = Vec::with_capacity(txs.len()); let mut tx_ids = Vec::with_capacity(txs.len()); + let block_number = state.next_block_number; + let logical_time = state.logical_time; + let parent_hash = state.parent_hash.clone(); for envelope in txs { tx_ids.push(envelope.tx_id.clone()); let authorization = envelope.authorization.clone(); let result = apply_transaction(state, &envelope.tx); - receipts.push(BlockReceipt { - tx_id: envelope.tx_id, - status: if result.is_ok() { - "applied" - } else { - "rejected" - } - .to_string(), - error: result.err().map(|error| error.to_string()), - authorization, - }); + let status = if result.is_ok() { + "applied" + } else { + "rejected" + } + .to_string(); + let error = result.err().map(|error| error.to_string()); + let event = event_from_tx(&envelope.tx_id, block_number, &envelope.tx, &status, &error); + let event_ids = vec![event.event_id.clone()]; + events.push(event.clone()); + state.events.insert(event.event_id.clone(), event); + + let receipt = BlockReceipt { + tx_id: envelope.tx_id.clone(), + status: status.clone(), + error: error.clone(), + block_number, + event_ids: event_ids.clone(), + authorization: authorization.clone(), + }; + let receipt_id = envelope.tx_id.clone(); + state.receipts.insert( + receipt_id.clone(), + StoredReceipt { + receipt_id: receipt_id.clone(), + tx_id: envelope.tx_id.clone(), + block_number, + status: status.clone(), + error, + event_ids, + state_root: ZERO_HASH.to_string(), + authorization: authorization.clone(), + }, + ); + state + .transactions + .entry(envelope.tx_id.clone()) + .and_modify(|stored| { + stored.status = status.clone(); + stored.included_at_block = Some(block_number); + stored.receipt_id = Some(receipt_id.clone()); + stored.authorization = authorization.clone(); + }) + .or_insert_with(|| StoredTransaction { + tx_id: envelope.tx_id.clone(), + tx: envelope.tx.clone(), + authorization, + status: status.clone(), + submitted_at_block: envelope.submitted_at_block, + included_at_block: Some(block_number), + receipt_id: Some(receipt_id.clone()), + }); + state + .consumed_tx_ids + .entry(envelope.tx_id.clone()) + .and_modify(|consumed| { + consumed.status = status.clone(); + consumed.included_at_block = Some(block_number); + }) + .or_insert_with(|| ConsumedTx { + tx_id: envelope.tx_id.clone(), + status, + first_seen_at_block: envelope.submitted_at_block, + included_at_block: Some(block_number), + }); + receipts.push(receipt); } 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(); + for receipt in state + .receipts + .values_mut() + .filter(|receipt| receipt.block_number == block_number) + { + receipt.state_root = root.clone(); + } + let receipt_root = map_root("flowmemory.local_devnet.block_receipts.v0", &state.receipts); + let event_root = map_root("flowmemory.local_devnet.block_events.v0", &state.events); let mut block = Block { schema: BLOCK_SCHEMA.to_string(), @@ -1239,7 +1806,11 @@ pub fn build_block(state: &mut ChainState) -> Block { logical_time, tx_ids, receipts, + events, state_root: root, + receipt_root, + event_root, + finalized_height: block_number.saturating_sub(1), block_hash: ZERO_HASH.to_string(), }; block.block_hash = hash_json("flowmemory.local_devnet.block_hash.v0", &block); @@ -1247,11 +1818,126 @@ pub fn build_block(state: &mut ChainState) -> Block { state.next_block_number += 1; state.logical_time += 1; state.parent_hash = block.block_hash.clone(); + state.latest_height = block.block_number; + state.latest_hash = block.block_hash.clone(); + state.finalized_height = block.finalized_height; state.blocks.push(block.clone()); block } +fn event_from_tx( + tx_id: &str, + block_number: u64, + tx: &Transaction, + status: &str, + error: &Option, +) -> BlockEvent { + let event_type = event_type_for_tx(tx); + let object_id = object_id_for_tx(tx); + let payload = serde_json::json!({ + "tx": tx, + "error": error, + }); + let event_id = hash_json( + "flowmemory.local_devnet.event_id.v0", + &serde_json::json!({ + "txId": tx_id, + "blockNumber": block_number, + "eventType": event_type, + "objectId": object_id, + "status": status + }), + ); + BlockEvent { + event_id, + tx_id: tx_id.to_string(), + block_number, + event_type: event_type.to_string(), + object_id, + status: status.to_string(), + payload, + } +} + +pub fn event_type_for_tx(tx: &Transaction) -> &'static str { + match tx { + Transaction::RegisterRootfield { .. } => "rootfield_registered", + Transaction::RegisterAgent { .. } => "agent_registered", + Transaction::CreateLocalTestUnitBalance { .. } => "local_balance_created", + Transaction::FaucetLocalTestUnits { .. } => "local_balance_faucet", + Transaction::TransferLocalTestUnits { .. } => "local_balance_transfer", + Transaction::LaunchToken { .. } => "token_launched", + Transaction::MintLocalTestToken { .. } => "token_minted", + Transaction::TransferLocalTestToken { .. } => "token_transferred", + Transaction::CreatePool { .. } => "pool_created", + Transaction::AddLiquidity { .. } => "liquidity_added", + Transaction::RemoveLiquidity { .. } => "liquidity_removed", + Transaction::SwapExactIn { .. } => "swap_executed", + Transaction::RegisterModelPassport { .. } => "model_passport_registered", + Transaction::CommitRoot { .. } => "root_committed", + Transaction::SubmitArtifactCommitment { .. } => "artifact_committed", + Transaction::MarkArtifactAvailability { .. } => "artifact_availability_marked", + Transaction::SubmitWorkReceipt { .. } => "work_receipt_submitted", + Transaction::SubmitVerifierReport { .. } => "verifier_report_submitted", + Transaction::RegisterVerifierModule { .. } => "verifier_module_registered", + Transaction::UpdateMemoryCell { .. } => "memory_cell_updated", + Transaction::OpenChallenge { .. } => "challenge_opened", + Transaction::ResolveChallenge { .. } => "challenge_resolved", + Transaction::FinalizeWorkReceipt { .. } => "work_receipt_finalized", + Transaction::AnchorBatchToBasePlaceholder { .. } => "base_anchor_placeholder_created", + Transaction::ApplyBridgeCredit { .. } => "bridge_credit_applied", + Transaction::RequestWithdrawal { .. } => "withdrawal_intent_requested", + Transaction::ImportFlowPulseObservation(_) => "flowpulse_observation_imported", + Transaction::ImportVerifierReport(_) => "verifier_report_imported", + } +} + +pub fn object_id_for_tx(tx: &Transaction) -> String { + match tx { + Transaction::RegisterRootfield { rootfield_id, .. } => rootfield_id.clone(), + Transaction::RegisterAgent { agent_id, .. } => agent_id.clone(), + Transaction::CreateLocalTestUnitBalance { account_id, .. } => account_id.clone(), + Transaction::FaucetLocalTestUnits { + faucet_record_id, .. + } => faucet_record_id.clone(), + Transaction::TransferLocalTestUnits { transfer_id, .. } => transfer_id.clone(), + Transaction::LaunchToken { token_id, .. } => token_id.clone(), + Transaction::MintLocalTestToken { mint_id, .. } => mint_id.clone(), + Transaction::TransferLocalTestToken { transfer_id, .. } => transfer_id.clone(), + Transaction::CreatePool { pool_id, .. } => pool_id.clone(), + Transaction::AddLiquidity { liquidity_id, .. } => liquidity_id.clone(), + Transaction::RemoveLiquidity { liquidity_id, .. } => liquidity_id.clone(), + Transaction::SwapExactIn { swap_id, .. } => swap_id.clone(), + Transaction::RegisterModelPassport { + model_passport_id, .. + } => model_passport_id.clone(), + Transaction::CommitRoot { rootfield_id, .. } => rootfield_id.clone(), + Transaction::SubmitArtifactCommitment { artifact_id, .. } => artifact_id.clone(), + Transaction::MarkArtifactAvailability { proof_id, .. } => proof_id.clone(), + Transaction::SubmitWorkReceipt { receipt_id, .. } => receipt_id.clone(), + Transaction::SubmitVerifierReport { report_id, .. } => report_id.clone(), + Transaction::RegisterVerifierModule { verifier_id, .. } => verifier_id.clone(), + Transaction::UpdateMemoryCell { memory_cell_id, .. } => memory_cell_id.clone(), + Transaction::OpenChallenge { challenge_id, .. } => challenge_id.clone(), + Transaction::ResolveChallenge { challenge_id, .. } => challenge_id.clone(), + Transaction::FinalizeWorkReceipt { + finality_receipt_id, + .. + } => finality_receipt_id.clone(), + Transaction::AnchorBatchToBasePlaceholder { + appchain_chain_id, .. + } => appchain_chain_id.clone(), + Transaction::ApplyBridgeCredit { credit_id, .. } => credit_id.clone(), + Transaction::RequestWithdrawal { + withdrawal_intent_id, + .. + } => withdrawal_intent_id.clone(), + Transaction::ImportFlowPulseObservation(observation) => observation.observation_id.clone(), + Transaction::ImportVerifierReport(report) => report.report_id.clone(), + } +} + pub fn apply_transaction(state: &mut ChainState, tx: &Transaction) -> Result<(), DevnetError> { match tx { Transaction::RegisterRootfield { @@ -1547,6 +2233,46 @@ pub fn apply_transaction(state: &mut ChainState, tx: &Transaction) -> Result<(), }, ); } + Transaction::TransferLocalTestToken { + transfer_id, + token_id, + from_account_id, + to_account_id, + amount_units, + memo, + } => { + if state.token_transfer_receipts.contains_key(transfer_id) { + return Err(DevnetError::TokenTransferAlreadyExists(transfer_id.clone())); + } + if *amount_units == 0 { + return Err(DevnetError::TokenAmountMustBePositive(transfer_id.clone())); + } + if !state.local_test_unit_balances.contains_key(from_account_id) { + return Err(DevnetError::LocalTestUnitBalanceMissing( + from_account_id.clone(), + )); + } + if !state.local_test_unit_balances.contains_key(to_account_id) { + return Err(DevnetError::LocalTestUnitBalanceMissing( + to_account_id.clone(), + )); + } + debit_asset_units(state, from_account_id, token_id, *amount_units)?; + credit_asset_units(state, to_account_id, token_id, *amount_units)?; + state.token_transfer_receipts.insert( + transfer_id.clone(), + LocalTestTokenTransferReceipt { + transfer_id: transfer_id.clone(), + token_id: token_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, + }, + ); + } Transaction::CreatePool { pool_id, base_asset_id, @@ -2281,6 +3007,207 @@ pub fn apply_transaction(state: &mut ChainState, tx: &Transaction) -> Result<(), } state.base_anchors.insert(anchor.anchor_id.clone(), anchor); } + Transaction::ApplyBridgeCredit { + credit_id, + observation_id, + deposit_id, + replay_key, + source_chain_id, + source_contract, + source_tx_hash, + source_log_index, + token, + asset_id, + recipient_account_id, + amount_units, + verifier, + evidence_hash, + local_only, + production_ready, + base_observed_at, + handoff_written_at, + node_ingested_at, + } => { + if *source_chain_id != 8453 { + return Err(DevnetError::BridgeWrongSourceChain(*source_chain_id)); + } + 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())); + } + let event_replay_key = bridge_source_replay_key( + *source_chain_id, + source_contract, + source_tx_hash, + *source_log_index, + ); + if state.bridge_replay_keys.contains_key(replay_key) { + return Err(DevnetError::BridgeReplayAlreadyConsumed(replay_key.clone())); + } + if state.bridge_replay_keys.values().any(|record| { + record.source_chain_id == *source_chain_id + && record.source_log_index == *source_log_index + && record.source_contract.eq_ignore_ascii_case(source_contract) + && record.source_tx_hash.eq_ignore_ascii_case(source_tx_hash) + }) { + return Err(DevnetError::BridgeSourceEventAlreadyConsumed( + event_replay_key.clone(), + )); + } + if asset_id != LOCAL_TEST_UNIT_ASSET_ID { + ensure_asset_exists(state, asset_id)?; + } + ensure_local_account_exists(state, recipient_account_id, verifier)?; + credit_asset_units(state, recipient_account_id, asset_id, *amount_units)?; + let credit_applied_at = now_rfc3339(); + let first_spendable_at = credit_applied_at.clone(); + let base_observed_at = timestamp_or_now(base_observed_at); + let handoff_written_at = timestamp_or_now(handoff_written_at); + let node_ingested_at = timestamp_or_now(node_ingested_at); + let latency = BridgeLatencyMeasurement { + base_observed_at: base_observed_at.clone(), + handoff_written_at: handoff_written_at.clone(), + node_ingested_at: node_ingested_at.clone(), + credit_applied_at: credit_applied_at.clone(), + first_spendable_at: first_spendable_at.clone(), + total_seconds: seconds_between(&handoff_written_at, &first_spendable_at), + }; + + state.bridge_observations.insert( + observation_id.clone(), + BridgeObservation { + observation_id: observation_id.clone(), + replay_key: replay_key.clone(), + source_chain_id: *source_chain_id, + source_contract: source_contract.clone(), + source_tx_hash: source_tx_hash.clone(), + source_log_index: *source_log_index, + deposit_id: deposit_id.clone(), + token: token.clone(), + asset_id: asset_id.clone(), + amount_units: *amount_units, + flowchain_recipient: recipient_account_id.clone(), + status: "applied".to_string(), + observed_at_block: state.next_block_number, + local_only: *local_only, + production_ready: *production_ready, + }, + ); + state.bridge_credits.insert( + credit_id.clone(), + BridgeCredit { + credit_id: credit_id.clone(), + receipt_id: credit_id.clone(), + observation_id: observation_id.clone(), + deposit_id: deposit_id.clone(), + replay_key: replay_key.clone(), + source_chain_id: *source_chain_id, + source_contract: source_contract.clone(), + source_tx_hash: source_tx_hash.clone(), + source_log_index: *source_log_index, + asset_id: asset_id.clone(), + token: token.clone(), + recipient_account_id: recipient_account_id.clone(), + amount_units: *amount_units, + verifier: verifier.clone(), + evidence_hash: evidence_hash.clone(), + status: "applied".to_string(), + credited_at_block: state.next_block_number, + local_only: *local_only, + production_ready: *production_ready, + base_observed_at: base_observed_at.clone(), + handoff_written_at: handoff_written_at.clone(), + node_ingested_at: node_ingested_at.clone(), + credit_applied_at: credit_applied_at.clone(), + first_spendable_at: first_spendable_at.clone(), + }, + ); + state.bridge_credit_receipts.insert( + credit_id.clone(), + BridgeCreditReceipt { + receipt_id: credit_id.clone(), + credit_id: credit_id.clone(), + observation_id: observation_id.clone(), + deposit_id: deposit_id.clone(), + replay_key: replay_key.clone(), + source_chain_id: *source_chain_id, + source_contract: source_contract.clone(), + source_tx_hash: source_tx_hash.clone(), + source_log_index: *source_log_index, + asset_id: asset_id.clone(), + token: token.clone(), + recipient_account_id: recipient_account_id.clone(), + amount_units: *amount_units, + verifier: verifier.clone(), + evidence_hash: evidence_hash.clone(), + status: "applied".to_string(), + credited_at_block: state.next_block_number, + local_only: *local_only, + production_ready: *production_ready, + latency, + }, + ); + state.bridge_replay_keys.insert( + replay_key.clone(), + BridgeReplayKey { + replay_key: replay_key.clone(), + credit_id: credit_id.clone(), + source_chain_id: *source_chain_id, + source_contract: source_contract.clone(), + source_tx_hash: source_tx_hash.clone(), + source_log_index: *source_log_index, + event_replay_key, + consumed_at_block: state.next_block_number, + }, + ); + } + Transaction::RequestWithdrawal { + withdrawal_intent_id, + credit_id, + account_id, + asset_id, + amount_units, + destination_chain_id, + base_recipient, + memo, + } => { + if state.withdrawal_intents.contains_key(withdrawal_intent_id) { + return Err(DevnetError::BridgeWithdrawalAlreadyExists( + withdrawal_intent_id.clone(), + )); + } + if *amount_units == 0 { + return Err(DevnetError::BridgeWithdrawalAmountMustBePositive( + withdrawal_intent_id.clone(), + )); + } + if !state.bridge_credits.contains_key(credit_id) { + return Err(DevnetError::BridgeCreditMissing(credit_id.clone())); + } + debit_asset_units(state, account_id, asset_id, *amount_units)?; + state.withdrawal_intents.insert( + withdrawal_intent_id.clone(), + WithdrawalIntent { + withdrawal_intent_id: withdrawal_intent_id.clone(), + credit_id: credit_id.clone(), + account_id: account_id.clone(), + asset_id: asset_id.clone(), + amount_units: *amount_units, + destination_chain_id: *destination_chain_id, + base_recipient: base_recipient.clone(), + status: "requested".to_string(), + requested_at_block: state.next_block_number, + memo: memo.clone(), + test_mode: true, + broadcast: false, + production_ready: false, + }, + ); + } Transaction::ImportFlowPulseObservation(observation) => { if observation.event_signature.to_lowercase() != FLOWPULSE_TOPIC0 { return Err(DevnetError::InvalidEventSignature( @@ -2454,6 +3381,28 @@ fn credit_asset_units( Ok(()) } +fn ensure_local_account_exists( + state: &mut ChainState, + account_id: &str, + owner: &str, +) -> Result<(), DevnetError> { + if !state.local_test_unit_balances.contains_key(account_id) { + state.local_test_unit_balances.insert( + account_id.to_string(), + LocalTestUnitBalance { + account_id: account_id.to_string(), + owner: owner.to_string(), + units: 0, + total_faucet_units: 0, + last_faucet_record_id: None, + updated_at_block: state.next_block_number, + no_value: true, + }, + ); + } + Ok(()) +} + fn checked_pool_add(pool_id: &str, left: u64, right: u64) -> Result { left.checked_add(right) .ok_or_else(|| DevnetError::PoolReserveOverflow(pool_id.to_string())) diff --git a/crates/flowmemory-devnet/tests/devnet_tests.rs b/crates/flowmemory-devnet/tests/devnet_tests.rs index 99287194..986055fe 100644 --- a/crates/flowmemory-devnet/tests/devnet_tests.rs +++ b/crates/flowmemory-devnet/tests/devnet_tests.rs @@ -1,8 +1,8 @@ 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, + apply_transaction, bridge_source_replay_key, 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, }; use flowmemory_devnet::{canonical_json, keccak_hex}; @@ -1363,6 +1363,187 @@ fn cli_static_peer_sync_reconciles_two_local_node_states() { std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); } +#[test] +fn cli_live_bridge_ingest_rejects_duplicate_source_event_and_survives_export_import() { + let temp = temp_dir("live-bridge-ingest"); + let state = temp.join("state.json"); + let node_dir = temp.join("node"); + let handoff = temp.join("live-handoff.json"); + let duplicate_handoff = temp.join("live-handoff-duplicate.json"); + let transfer = temp.join("transfer.json"); + let snapshot = temp.join("snapshot.json"); + let imported = temp.join("imported-state.json"); + let source_contract = "0x1111111111111111111111111111111111111111"; + let source_tx_hash = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let replay_key = bridge_source_replay_key(8453, source_contract, source_tx_hash, 0); + write_live_handoff( + &handoff, + &replay_key, + "0x01", + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ); + + let ingest = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "--node-dir", + node_dir.to_str().expect("node dir"), + "bridge-ingest", + "--handoff", + handoff.to_str().expect("handoff path"), + "--direct", + "--require-live", + "--authorized-by", + "operator:bridge:test", + ]) + .output() + .expect("live bridge ingest"); + assert!( + ingest.status.success(), + "{}", + String::from_utf8_lossy(&ingest.stderr) + ); + + let include = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args(["--state", state.to_str().expect("state path"), "run-block"]) + .status() + .expect("include bridge credit"); + assert!(include.success()); + + let state_json = read_json(&state); + assert_eq!(state_json["bridgeCredits"].as_object().unwrap().len(), 1); + assert_eq!( + state_json["bridgeCreditReceipts"] + .as_object() + .unwrap() + .len(), + 1 + ); + assert_eq!(state_json["bridgeReplayKeys"].as_object().unwrap().len(), 1); + let credit = &state_json["bridgeCredits"]["0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]; + let account_id = credit["recipientAccountId"].as_str().unwrap(); + assert_eq!(credit["productionReady"], true); + assert_eq!(credit["localOnly"], false); + assert_eq!(state_json["localTestUnitBalances"][account_id]["units"], 50); + + write_live_handoff( + &duplicate_handoff, + "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "0x02", + "0xbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbc", + ); + let duplicate = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "--node-dir", + node_dir.to_str().expect("node dir"), + "bridge-ingest", + "--handoff", + duplicate_handoff.to_str().expect("duplicate handoff path"), + "--direct", + "--require-live", + "--authorized-by", + "operator:bridge:test", + ]) + .output() + .expect("duplicate live bridge ingest"); + assert!(duplicate.status.success()); + let duplicate_json: serde_json::Value = + serde_json::from_slice(&duplicate.stdout).expect("duplicate ingest json"); + assert!( + duplicate_json["queued"]["rejected"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["reason"] + .as_str() + .unwrap_or_default() + .contains("bridge source event already consumed")) + ); + + std::fs::write( + &transfer, + format!( + r#"{{ + "schema": "flowmemory.local_devnet.runtime_batch.v0", + "txs": [ + {{ "type": "CreateLocalTestUnitBalance", "accountId": "local-account:bridge:test-receiver", "owner": "operator:bridge:test" }}, + {{ "type": "TransferLocalTestUnits", "transferId": "transfer:bridge:live-test:001", "fromAccountId": "{account_id}", "toAccountId": "local-account:bridge:test-receiver", "amountUnits": 7, "memo": "live-bridge-transferability-test" }} + ] +}}"# + ), + ) + .expect("write transfer tx"); + let transfer_submit = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "submit-tx", + "--tx-file", + transfer.to_str().expect("transfer path"), + "--direct", + "--authorized-by", + "operator:bridge:test", + ]) + .status() + .expect("submit transfer"); + assert!(transfer_submit.success()); + let transfer_include = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args(["--state", state.to_str().expect("state path"), "run-block"]) + .status() + .expect("include transfer"); + assert!(transfer_include.success()); + let after_transfer = read_json(&state); + assert_eq!( + after_transfer["localTestUnitBalances"]["local-account:bridge:test-receiver"]["units"], + 7 + ); + + let export = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + state.to_str().expect("state path"), + "export-state", + "--out", + snapshot.to_str().expect("snapshot path"), + ]) + .status() + .expect("export state"); + assert!(export.success()); + let import = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet")) + .args([ + "--state", + imported.to_str().expect("imported state"), + "import-state", + "--from", + snapshot.to_str().expect("snapshot path"), + ]) + .status() + .expect("import state"); + assert!(import.success()); + let imported_json = read_json(&imported); + assert_eq!( + after_transfer["bridgeCredits"], + imported_json["bridgeCredits"] + ); + assert_eq!( + after_transfer["bridgeCreditReceipts"], + imported_json["bridgeCreditReceipts"] + ); + assert_eq!( + after_transfer["bridgeReplayKeys"], + imported_json["bridgeReplayKeys"] + ); + assert_eq!( + after_transfer["localTestUnitBalances"][account_id], + imported_json["localTestUnitBalances"][account_id] + ); + + std::fs::remove_dir_all(&temp).expect("cleanup temp dir"); +} + #[test] fn zero_hash_constant_is_hex_32_bytes() { assert_eq!(ZERO_HASH.len(), 66); @@ -1384,6 +1565,94 @@ fn inspect_summary(state: &std::path::Path, node_dir: &std::path::Path) -> serde serde_json::from_slice(&output.stdout).expect("status json") } +fn read_json(path: &std::path::Path) -> serde_json::Value { + serde_json::from_str(&std::fs::read_to_string(path).expect("read json")).expect("parse json") +} + +fn write_live_handoff(path: &std::path::Path, replay_key: &str, nonce: &str, credit_id: &str) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_secs() + .to_string(); + std::fs::write( + path, + format!( + r#"{{ + "schema": "flowmemory.bridge_runtime_handoff.v0", + "handoffId": "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "generatedAt": "{now}", + "handoffWrittenAt": "{now}", + "mode": "base-mainnet-pilot", + "productionReady": true, + "localOnly": false, + "observations": [ + {{ + "schema": "flowmemory.bridge_deposit_observation.v0", + "observationId": "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "replayKey": "{replay_key}", + "observedAt": "{now}", + "mode": "base-mainnet-pilot", + "productionReady": true, + "deposit": {{ + "schema": "flowmemory.bridge_deposit.v0", + "depositId": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "sourceChainId": 8453, + "sourceContract": "0x1111111111111111111111111111111111111111", + "txHash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "logIndex": 0, + "token": "0x3333333333333333333333333333333333333333", + "amount": "50", + "sender": "0x4444444444444444444444444444444444444444", + "flowchainRecipient": "0x5555555555555555555555555555555555555555555555555555555555555555", + "nonce": "{nonce}", + "status": "observed" + }}, + "guardrails": {{ + "explicitChainId": true, + "explicitContract": true, + "explicitBlockRange": true, + "noSecrets": true, + "confirmation": {{ + "depth": 12, + "satisfied": true + }} + }} + }} + ], + "credits": [ + {{ + "schema": "flowmemory.bridge_credit.v0", + "creditId": "{credit_id}", + "observationId": "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "depositId": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "replayKey": "{replay_key}", + "source": {{ + "chainId": 8453, + "contract": "0x1111111111111111111111111111111111111111", + "txHash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "logIndex": 0 + }}, + "token": "0x3333333333333333333333333333333333333333", + "amount": "50", + "flowchainRecipient": "0x5555555555555555555555555555555555555555555555555555555555555555", + "status": "applied", + "localOnly": false, + "productionReady": true + }} + ], + "withdrawalIntents": [], + "replayProtection": {{ + "strategy": "source-chain-contract-tx-log-deposit", + "replayKeys": ["{replay_key}"], + "duplicateReplayKeys": [] + }} +}}"# + ), + ) + .expect("write live handoff"); +} + fn temp_dir(name: &str) -> std::path::PathBuf { let temp = std::env::temp_dir().join(format!( "flowmemory-devnet-test-{}-{name}", diff --git a/docs/FLOWCHAIN_TESTNET_ACCEPTANCE.md b/docs/FLOWCHAIN_TESTNET_ACCEPTANCE.md index e161c455..e993a553 100644 --- a/docs/FLOWCHAIN_TESTNET_ACCEPTANCE.md +++ b/docs/FLOWCHAIN_TESTNET_ACCEPTANCE.md @@ -1,9 +1,9 @@ # FlowChain Testnet Acceptance Status: acceptance matrix for the private/local testnet package. The HQ/Ops -command wrapper layer and full-smoke gate are implemented for current -private/local surfaces. Workbench polish and longer-running runtime behavior -remain active, but the local object/control-plane path is no longer blocked. +command wrapper layer, full-smoke gate, and production-style local node runtime +are implemented for current private/local surfaces. Workbench polish remains +active, but the local object/control-plane/runtime path is no longer blocked. This document marks every major feature as one of: @@ -22,9 +22,9 @@ This document marks every major feature as one of: | Run devnet tests | Implemented | `cargo test --manifest-path crates/flowmemory-devnet/Cargo.toml`. | | Run service tests | Implemented | `npm test` for merged service packages. | | Run launch candidate gate | Implemented | `npm run launch:candidate`. | -| One-command private testnet aliases | Implemented for merged surfaces | `package.json` now exposes `flowchain:prereq`, `flowchain:init`, `flowchain:start`, `flowchain:stop`, `flowchain:demo`, `flowchain:smoke`, `flowchain:full-smoke`, `flowchain:export`, `flowchain:import`, `control-plane:serve`, and `workbench:dev`. | +| One-command private testnet aliases | Implemented for merged surfaces | `package.json` now exposes `flowchain:prereq`, `flowchain:init`, `flowchain:start`, `flowchain:stop`, `flowchain:node:start`, `flowchain:node`, `flowchain:node:stop`, `flowchain:node:status`, `flowchain:node:restart`, `flowchain:bridge:ingest`, `flowchain:wallet:transfer:e2e`, `flowchain:restart:verify`, `flowchain:live-bridge:status`, `flowchain:no-secret:scan`, `flowchain:tx`, `flowchain:node:smoke`, `flowchain:demo`, `flowchain:smoke`, `flowchain:full-smoke`, `flowchain:export`, `flowchain:import`, `control-plane:serve`, and `workbench:dev`. | | Prerequisite check script | Implemented | `infra/scripts/flowchain-check-prereqs.ps1`. | -| Start/stop scripts | Implemented bounded wrappers | `flowchain:start` prepares launch-core fixtures and state summary; `flowchain:stop` records stopped state and can reset ignored local state. Long-running node behavior remains in flight. | +| Start/stop scripts | Implemented | `flowchain:start` prepares launch-core fixtures and state summary; `flowchain:node`, `flowchain:node:stop`, `flowchain:node:status`, and `flowchain:node:restart` operate the persistent private/local node. | | Full smoke script | Implemented for current private/local surfaces | `flowchain:full-smoke` runs the smoke gate, control-plane smoke client, deterministic replay, dashboard build, hardware fixture, unsafe-claim scan, export no-secret scan, and `git diff --check`. | | Export/import state bundles | Implemented local wrapper | `flowchain:export` writes ignored export files and zip bundle; `flowchain:import` restores local state from a bundle. | | Troubleshooting guide | Implemented | `docs/FLOWCHAIN_TROUBLESHOOTING.md` plus script error messages. | @@ -35,14 +35,17 @@ This document marks every major feature as one of: | --- | --- | --- | | No-value deterministic devnet | Implemented | Existing Rust devnet remains the single runtime surface. | | Private/local genesis/config | Implemented local boundary | `flowchain:init` and devnet exports write deterministic local genesis/config references. | -| Single-node local runtime | Implemented bounded local runtime | Current CLI can init/demo/run blocks and `flowchain:start` gives an obvious bounded start path; long-running node behavior remains future polish. | +| Single-node local runtime | Implemented persistent local runtime | Current CLI can start a persistent node, stop it, restart it, submit transactions, produce bounded or interval blocks, persist receipts/events, and expose status. | | Multi-node or LAN notes | Missing | Must be optional and safe, or marked later gated. | | Deterministic block production | Implemented | Current devnet models deterministic blocks and state roots. | | Deterministic replay | Implemented | `flowchain:smoke` reruns the native object demo twice and compares block hashes, latest parent hash, state root, and map roots. | -| Transaction ingestion | Implemented local fixture path | Current devnet supports fixture submission plus deterministic demo transactions for the local object lifecycle. | +| Transaction ingestion | Implemented local runtime path | Current devnet supports signed transaction envelopes, local authorized tx files, file inbox ingestion, duplicate/replay rejection, nonce checks, and deterministic block inclusion. | | State export | Implemented | `export-fixtures` exists and is exercised by the package-level smoke path. | -| State import/snapshot restore | Implemented local wrapper | `flowchain:import` restores current devnet state from an exported bundle; richer subsystem snapshots remain future work. | -| Health/status output | Implemented local control-plane path | CLI summary and `control-plane:smoke` exercise local health/status queries. | +| State import/snapshot restore | Implemented local wrapper and runtime CLI | `flowchain:import` restores current devnet state from an exported bundle; `export-state` and `import-state` preserve deterministic runtime roots. | +| Health/status output | Implemented local control-plane and node path | CLI summary, `node-status`, status JSON, and `control-plane:smoke` exercise local health/status queries. | +| Runtime receipts and events | Implemented | Block production writes queryable transaction, receipt, and event indexes that survive restart. | +| Bridge credit local spendability | Implemented local/private path | `ApplyBridgeCredit` requires Base source chain id `8453`, stores a replay key, stores a bridge credit receipt, rejects duplicate source chain/contract/tx/log applications, credits local balance once, and smoke/tests prove local spendability plus withdrawal intent recording. | +| Live pilot handoff intake | Implemented code path, externally gated for real handoff evidence | `flowchain:bridge:ingest` consumes an explicit `flowmemory.bridge_runtime_handoff.v0` handoff into the main `devnet/local/state.json` when it is `productionReady: true`, `localOnly: false`, Base `8453`, and 12-confirmation eligible. Real live pass still requires an external Base handoff file. | ## Native Objects @@ -149,10 +152,15 @@ The package is accepted only when one documented command can: Current wrapper status: - `npm run flowchain:full-smoke` is the documented acceptance command. +- `npm run flowchain:node:smoke` is the documented production-node runtime proof. - It proves the merged launch-core, crypto helpers/vectors, local devnet, export, dashboard build, hardware fixture, deterministic replay, control-plane query coverage, native local object lifecycle, and claim/no-secret guardrails. +- The node smoke proves signed submit, block inclusion, receipt query, balance + update, restart, replay rejection, bridge credit spendability, and + export/import root preservation. The evidence file is + `devnet/local/node-smoke/production-node-smoke-report.json`. Required final evidence for the acceptance PR: diff --git a/docs/LOCAL_DEVNET.md b/docs/LOCAL_DEVNET.md index dd5a2424..db04cf15 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 node runtime -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 node/CLI that models FlowMemory appchain-style state transitions without production consensus, tokenomics, 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. @@ -25,6 +25,7 @@ From repo root: ```powershell cargo test --manifest-path crates/flowmemory-devnet/Cargo.toml +npm run flowchain:node:smoke ``` ## Commands @@ -34,6 +35,17 @@ Windows-first root wrappers: ```powershell npm run flowchain:init npm run flowchain:start +npm run flowchain:node:start +npm run flowchain:node +npm run flowchain:node:stop +npm run flowchain:node:status +npm run flowchain:node:restart +npm run flowchain:bridge:ingest -- -HandoffPath +npm run flowchain:tx -- --tx-file +npm run flowchain:wallet:transfer:e2e +npm run flowchain:restart:verify +npm run flowchain:live-bridge:status +npm run flowchain:node:smoke npm run flowchain:demo npm run flowchain:full-smoke npm run flowchain:export @@ -41,8 +53,9 @@ npm run flowchain:stop ``` The wrappers call the Rust CLI below and write ignored operator/status/handoff/ -export files under `devnet/local/`. The current runtime is still a -deterministic local CLI, not a long-running node. +export files under `devnet/local/`. The node wrapper starts the persistent +private/local runtime. Compatibility wrappers such as `flowchain:start` still +prepare launch-core fixtures and point operators at the node command. Initialize state: @@ -60,6 +73,57 @@ devnet/local/operator-key-references.json The operator key file is a reference boundary only. It records local fixture identifiers and crypto schema references, but no signing secret material. +Start the persistent local node: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- node --node-id node:local:one --block-ms 1000 +npm run flowchain:node:start +``` + +Run a bounded node loop for automation: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- node --max-blocks 10 +``` + +Stop, restart, and inspect node status: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- node-stop +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- node-restart --max-blocks 1 +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- node-status +``` + +The status output includes chain id, height, latest hash, finalized height, +state root, receipt root, event root, mempool size, log path, and last error. + +## Live Pilot Bridge Intake + +The live pilot intake path is explicit and handoff-file based. It does not +broadcast Base transactions. A running node consumes a +`flowmemory.bridge_runtime_handoff.v0` file only when the handoff is marked +`productionReady: true`, `localOnly: false`, uses Base source chain id `8453`, +and includes satisfied 12-confirmation evidence. + +```powershell +npm run flowchain:node:start +npm run flowchain:bridge:ingest -- -HandoffPath devnet/local/live-base8453-pilot-runtime/base8453-handoff-applied.json +npm run flowchain:wallet:transfer:e2e +npm run flowchain:restart:verify +npm run flowchain:live-bridge:status +npm run flowchain:no-secret:scan +``` + +Reports are written under: + +```text +devnet/local/live-l1-bridge-intake/ +``` + +The main runtime state is still `devnet/local/state.json`; bridge credits, +bridge credit receipts, replay keys, credited balances, and transfer receipts +must land there rather than in a temporary proof-only directory. + Reset local state: ```powershell @@ -84,12 +148,31 @@ Submit a transaction fixture: cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- submit-fixture --fixture fixtures/handoff/sample-txs.json ``` +Submit a signed or locally authorized transaction file: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- submit-tx --tx-file devnet/local/node-smoke/tx/signed-register-agent.json +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- list-mempool +``` + +`submit-tx` accepts signed local transaction envelopes, a single `tx`, or a +batch under `txs`. A running node also ingests transaction JSON files from its +local inbox under `/tx/`, moves accepted or processed files under +`/processed/`, and writes structured rejection evidence for invalid +submissions. + Build a block from pending transactions: ```powershell cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- run-block ``` +Manually tick the node block producer: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- tick +``` + Run a bounded local block-production loop: ```powershell @@ -115,6 +198,17 @@ 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 production node smoke: + +```powershell +npm run flowchain:node:smoke +``` + +The node smoke starts a node process, submits a signed envelope and a local +batch, produces at least 10 blocks, queries the tx and receipt, restarts the +node, rejects a replay, verifies bridge credit spendability, exports/imports +state, and writes `devnet/local/node-smoke/production-node-smoke-report.json`. + Import a FlowPulse observation fixture: ```powershell @@ -144,6 +238,19 @@ cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- export-state -- cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state devnet/local/imported-state.json import-state --from fixtures/handoff/generated/state-snapshot.json ``` +Query runtime state: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query-block --id 1 +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query-tx --id +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query-receipt --id +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query-account --id +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query-token --id +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query-pool --id +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query-bridge-credit --id +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query-finality --id +``` + Use a custom state path: ```powershell @@ -181,11 +288,16 @@ The demo: The prototype stores: - `config` +- `latestHeight` +- `latestHash` +- `finalizedHeight` - `operatorKeyReferences` - `rootfields` - `agentAccounts` +- `accountNonces` - `localTestUnitBalances` - `faucetRecords` +- `balanceTransfers` - `modelPassports` - `memoryCells` - `challenges` @@ -198,10 +310,29 @@ The prototype stores: - `importedObservations` - `importedVerifierReports` - `baseAnchors` +- `tokenDefinitions` +- `tokenBalances` +- `tokenMintReceipts` +- `tokenTransferReceipts` +- `dexPools` +- `lpPositions` +- `liquidityReceipts` +- `swapReceipts` +- `bridgeObservations` +- `bridgeCredits` +- `bridgeReplayKeys` +- `withdrawalIntents` +- `transactions` +- `receipts` +- `events` +- `consumedTxs` +- `replayKeys` - `blocks` - `pendingTxs` -`localTestUnitBalances` and `faucetRecords` are deterministic, no-value local records for runtime testing only. They are not token balances and there is no gas accounting. +`localTestUnitBalances`, `faucetRecords`, bridge credits, and withdrawal intents +are deterministic, no-value local records for runtime testing only. They are not +production assets and there is no gas accounting. ## Transaction Types @@ -211,7 +342,17 @@ Supported local transactions: - `RegisterAgent` - `CreateLocalTestUnitBalance` - `FaucetLocalTestUnits` +- `TransferLocalTestUnits` - `RegisterModelPassport` +- `LaunchLocalTestToken` +- `MintLocalTestToken` +- `TransferLocalTestToken` +- `CreateLocalTestPool` +- `AddLocalTestLiquidity` +- `RemoveLocalTestLiquidity` +- `SwapLocalTestTokens` +- `ApplyBridgeCredit` +- `RequestWithdrawal` - `CommitRoot` - `SubmitArtifactCommitment` - `MarkArtifactAvailability` @@ -231,6 +372,9 @@ Supported local transactions: - Agent and model records are identity/provenance records only; they do not hold balances. - Local test-unit balance records are no-value runtime fixtures only; they do not create a token, monetary claim, fee market, staking role, reward, or bridge asset. - Faucet records require an existing local test-unit balance, a unique faucet record id, and a positive amount. +- Local test-unit transfers require positive amounts and sufficient local balance. +- Bridge credits require Base source chain id `8453`, a unique replay key, and positive credit amount; they are local/private handoff records only. +- Withdrawal intents debit local spendable balance and record a test-mode intent without broadcasting a production withdrawal. - Work receipts must reference an existing artifact commitment in the same rootfield. - Verifier reports must reference an existing active verifier module and an existing receipt in the same rootfield. - Memory cells can be created or updated only from an existing work receipt with an accepted local verifier report. @@ -249,7 +393,11 @@ Each block has: - Logical time. - Transaction ids. - Receipts. +- Events. - State root. +- Receipt root. +- Event root. +- Finalized height. - 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. @@ -262,6 +410,10 @@ Default local state: ```text devnet/local/state.json +devnet/local/node/status.json +devnet/local/node/node.log.jsonl +devnet/local/runtime-handoff.json +devnet/local/handoff/ ``` `devnet/local/` is ignored by git. @@ -278,14 +430,30 @@ Generated exports: - `fixtures/handoff/generated/operator-key-references.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. +Runtime handoff files: + +- `devnet/local/runtime-handoff.json` +- `devnet/local/handoff/dashboard-state.json` +- `devnet/local/handoff/indexer-handoff.json` +- `devnet/local/handoff/verifier-handoff.json` +- `devnet/local/handoff/control-plane-handoff.json` +- `devnet/local/handoff/genesis-config.json` +- `devnet/local/handoff/operator-key-references.json` +- `devnet/local/handoff/state.json` + +The generated dashboard, indexer, verifier, and state outputs include the expanded local object maps, transaction indexes, receipt/event indexes, bridge-credit state, withdrawal intents, 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. -Control-plane and dashboard agents should read: +Control-plane, RPC, and dashboard agents should read: +- `latestHeight`, `latestHash`, `finalizedHeight`, `stateRoot`, `receiptRoot`, and `eventRoot` for chain status. +- `pendingTxs`, `accountNonces`, `consumedTxs`, and `bridgeReplayKeys` for mempool/replay state. +- `transactions`, `receipts`, and `events` for query surfaces. - `objects.localTestUnitBalances` and `objects.faucetRecords` from `control-plane-handoff.json`. +- `objects.bridgeCredits`, `objects.bridgeObservations`, and `objects.withdrawalIntents` from `control-plane-handoff.json`. - Top-level `localTestUnitBalances` and `faucetRecords` from `dashboard-state.json`. +- Top-level `transactions`, `receipts`, `events`, `bridgeCredits`, and `withdrawalIntents` from `dashboard-state.json`. - `mapRoots.localTestUnitBalanceRoot` and `mapRoots.faucetRecordRoot` anywhere map-root reconciliation is needed. ## Non-Goals diff --git a/docs/agent-runs/production-l1-runtime/BLOCK_PRODUCTION_PROOF.md b/docs/agent-runs/production-l1-runtime/BLOCK_PRODUCTION_PROOF.md new file mode 100644 index 00000000..96c6fd3d --- /dev/null +++ b/docs/agent-runs/production-l1-runtime/BLOCK_PRODUCTION_PROOF.md @@ -0,0 +1,41 @@ +# Block Production Proof + +## Behavior + +The node can produce blocks through: + +- Manual tick: `tick`. +- Single block compatibility path: `run-block`. +- Bounded loop: `node --max-blocks `. +- Operator loop: `node --block-ms `. + +Each produced block records: + +- Block number. +- Parent hash. +- Logical time. +- Deterministically ordered transaction ids. +- Per-transaction receipts. +- Per-transaction events. +- State root. +- Receipt root. +- Event root. +- Finalized height. +- Block hash. + +The same block construction path is used by manual and node-loop block production. + +## Smoke Evidence + +`npm run flowchain:node:smoke` produced 21 persisted blocks after the restart leg. The final status file recorded: + +```text +latestHeight: 21 +finalizedHeight: 20 +latestHash: 0xd16b721acf982ecec33b49f35c4b031d1cac47c6526ae28daa336e6f1b8716cd +stateRoot: 0x3e362fa09ddd18626c6213f49863531c7e93cd7c13708894aa19ff9d700201e8 +receiptRoot: 0xd251f2173f1458704e07290d7af33f7b0b2dc783edd0f74a545d1363f0c3d053 +eventRoot: 0x83e9f2740d4d0fa2514e7180636a7f1517335398593cdc2430b104b845ae4635 +``` + +The smoke report is `devnet/local/node-smoke/production-node-smoke-report.json`. diff --git a/docs/agent-runs/production-l1-runtime/BRIDGE_CREDIT_SPEND_PROOF.md b/docs/agent-runs/production-l1-runtime/BRIDGE_CREDIT_SPEND_PROOF.md new file mode 100644 index 00000000..0448457c --- /dev/null +++ b/docs/agent-runs/production-l1-runtime/BRIDGE_CREDIT_SPEND_PROOF.md @@ -0,0 +1,47 @@ +# Bridge Credit Spend Proof + +## Behavior + +The local runtime implements a private/test bridge-credit execution path for Base evidence handoff: + +- `ApplyBridgeCredit` requires `sourceChainId` `8453`. +- The source tx hash, log index, source contract, deposit id, observation id, evidence hash, verifier, and replay key are stored. +- The replay key is consumed exactly once. +- The local recipient is credited with local test units or an existing local token asset. +- A `bridge_credit_applied` event and receipt are written. +- The credited account can spend local test units through `TransferLocalTestUnits`. +- `RequestWithdrawal` records a test-mode withdrawal intent without broadcasting a production withdrawal. + +This is local/private runtime behavior only. It is not an audited bridge or production withdrawal system. + +## Smoke Evidence + +`npm run flowchain:node:smoke` applied credit: + +```text +creditId: bridge-credit:node-smoke:001 +sourceChainId: 8453 +recipientAccountId: local-account:bridge:alice +amountUnits: 75 +``` + +Alice then spent 25 local test units to: + +```text +local-account:bridge:bob +``` + +The smoke report recorded: + +```text +recipientCanSpend: true +bobBalance: 25 +withdrawalIntentRecorded: true +``` + +Queryable paths: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query-bridge-credit --id bridge-credit:node-smoke:001 +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query-account --id local-account:bridge:bob +``` diff --git a/docs/agent-runs/production-l1-runtime/CHECKLIST.md b/docs/agent-runs/production-l1-runtime/CHECKLIST.md new file mode 100644 index 00000000..1e64acc8 --- /dev/null +++ b/docs/agent-runs/production-l1-runtime/CHECKLIST.md @@ -0,0 +1,14 @@ +# Production L1 Runtime Checklist + +- [x] Current runtime map documented. +- [x] Runtime config includes chain id, network profile, genesis path, data dir, block interval, signer identity reference, and peer config path. +- [x] Persistent store tracks latest/finalized block, roots, accounts, balances, token state, pools, LP positions, bridge credits, receipts, and events. +- [x] Mempool accepts signed or locally authorized envelopes exactly once. +- [x] Mempool rejects wrong chain, malformed signature, duplicate tx id, replay, stale nonce, future nonce conflict, insufficient balance, unknown payload, and bridge replay. +- [x] Block production supports manual tick and interval or bounded loop mode. +- [x] Queries cover status, mempool, block, transaction, receipt, account, token, pool, bridge credit, and finality receipt. +- [x] Restart preserves height, latest hash, finalized height, state root, balances, receipts, pending mempool transactions, and bridge replay keys. +- [x] Export/import preserves deterministic roots. +- [x] Handoff JSON is written for control-plane and dashboard agents. +- [x] Required proof and handoff docs are written. +- [x] Final cargo tests, node smoke, npm smoke, and `git diff --check` pass. diff --git a/docs/agent-runs/production-l1-runtime/EXPERIMENTS.md b/docs/agent-runs/production-l1-runtime/EXPERIMENTS.md new file mode 100644 index 00000000..b1817378 --- /dev/null +++ b/docs/agent-runs/production-l1-runtime/EXPERIMENTS.md @@ -0,0 +1,11 @@ +# Production L1 Runtime Experiments + +| Experiment | Command | Result | Notes | +| --- | --- | --- | --- | +| Baseline Rust tests | `cargo test --manifest-path crates/flowmemory-devnet/Cargo.toml` | Passed | Initial run passed 27 tests before implementation changes. | +| Runtime Rust tests | `cargo test --manifest-path crates/flowmemory-devnet/Cargo.toml` | Passed | Final run passed 27 tests. | +| Signed envelope direct submit | `flowmemory-devnet submit-tx --tx-file --direct` | Passed | Accepted fixture tx `0xfba94617ac6fbae608393c67570280d7123b27dabb0c1f31427808ad955a7c46`; second submit rejected as `duplicate-tx-id`. | +| Block/query direct path | `flowmemory-devnet run-block`, `query-tx`, `query-receipt` | Passed | Signed tx became queryable with a stored receipt after block production. | +| Node smoke | `npm run flowchain:node:smoke` | Passed | Final run produced 21 blocks, accepted 26 txs, queried receipts, restarted, rejected replay, and preserved export/import roots. | +| Smoke report | `devnet/local/node-smoke/production-node-smoke-report.json` | Written | State root `0x3e362fa09ddd18626c6213f49863531c7e93cd7c13708894aa19ff9d700201e8`. | +| Diff whitespace | `git diff --check` | Passed | Required final gate. | diff --git a/docs/agent-runs/production-l1-runtime/HANDOFF.md b/docs/agent-runs/production-l1-runtime/HANDOFF.md new file mode 100644 index 00000000..bc249363 --- /dev/null +++ b/docs/agent-runs/production-l1-runtime/HANDOFF.md @@ -0,0 +1,107 @@ +# Production L1 Runtime Handoff + +## Command List + +Root npm aliases: + +```powershell +npm run flowchain:node +npm run flowchain:node:stop +npm run flowchain:node:status +npm run flowchain:node:restart +npm run flowchain:tx -- --tx-file +npm run flowchain:node:smoke +``` + +Direct Rust commands: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- node +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- node-stop +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- node-status +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- tick +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- submit-tx --tx-file +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- list-mempool +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query --kind --id +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- export-state --out +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- import-state --from +``` + +## Runtime Output Paths + +- State: `devnet/local/state.json` +- Node status: `devnet/local/node/status.json` +- Node log: `devnet/local/node/node.log.jsonl` +- Node identity reference: `devnet/local/node/node-identity.json` +- Runtime handoff: `devnet/local/runtime-handoff.json` +- Preferred control-plane handoff: `devnet/local/handoff/control-plane-handoff.json` +- Preferred dashboard handoff: `devnet/local/handoff/dashboard-state.json` +- Preferred indexer handoff: `devnet/local/handoff/indexer-handoff.json` +- Preferred verifier handoff: `devnet/local/handoff/verifier-handoff.json` +- Smoke report: `devnet/local/node-smoke/production-node-smoke-report.json` + +## Transaction Intake Path + +Use `submit-tx --tx-file ` for direct local submission. A running node also ingests JSON transaction files from its local inbox under `/tx/`; processed files are moved under `/processed/`, and rejected intake writes structured evidence with a rejection reason. + +Accepted transaction files can contain: + +- A signed `flowchain.local_transaction_envelope.v0` envelope. +- A single local `tx`. +- A batch under `txs`. + +## State Query Paths + +Use CLI query commands now; an RPC agent should map them directly: + +- `node-status` for chain/node status. +- `list-mempool` for pending txs. +- `query-block --id `. +- `query-tx --id `. +- `query-receipt --id `. +- `query-account --id `. +- `query-token --id `. +- `query-pool --id `. +- `query-bridge-credit --id `. +- `query-finality --id `. + +## RPC Fields To Expose + +Expose these fields from `node-status` and the handoff JSON: + +- `chainId` +- `networkProfile` +- `genesisPath` +- `dataDirectory` +- `blockIntervalMs` +- `validatorIdentityRef` +- `peerConfigPath` +- `status` +- `nodeId` +- `statePath` +- `nodeDir` +- `latestHeight` +- `latestHash` +- `finalizedHeight` +- `stateRoot` +- `receiptRoot` +- `eventRoot` +- `pendingTxs` +- `maxMempoolTxs` +- `accountNonces` +- `consumedTxs` +- `bridgeReplayKeys` +- `receipts` +- `events` +- `logPath` +- `lastError` + +For object queries, expose accounts, local balances, token definitions, token balances, pools, LP positions, bridge observations, bridge credits, withdrawal intents, transactions, receipts, and events. + +## Incomplete Execution Payloads + +- Production consensus and public peer networking are not implemented. +- Gas, fee market, tokenomics, staking, rewards, and slashing are not implemented. +- Bridge credit is a local/private execution handoff, not audited production bridge security. +- Withdrawal intent records local requested withdrawals but does not broadcast a live Base release. +- ToolReceipt, EvalReceipt, and DependencyAtom remain outside this runtime change unless mapped by another agent. diff --git a/docs/agent-runs/production-l1-runtime/LIVE_NODE_BRIDGE_INTAKE.md b/docs/agent-runs/production-l1-runtime/LIVE_NODE_BRIDGE_INTAKE.md new file mode 100644 index 00000000..6ab15239 --- /dev/null +++ b/docs/agent-runs/production-l1-runtime/LIVE_NODE_BRIDGE_INTAKE.md @@ -0,0 +1,108 @@ +# Live Node Bridge Intake Evidence + +Status: EXTERNAL-BLOCKED on live Base 8453 handoff availability. + +Run directory: + +```text +devnet/local/live-l1-bridge-intake/ +``` + +## What Changed + +- Added unbounded background node start wrapper: + `npm run flowchain:node:start`. +- Added explicit live bridge handoff intake: + `npm run flowchain:bridge:ingest -- -HandoffPath `. +- Added bridge credit receipts in main runtime state: + `bridgeCreditReceipts`. +- Preserved replay protection against the Base + `sourceChainId/sourceContract/txHash/logIndex` event, even if a duplicate + handoff changes `replayKey`. +- Added spend proof, restart/export/import proof, live status report, and + no-secret scan wrappers: + `flowchain:wallet:transfer:e2e`, `flowchain:restart:verify`, + `flowchain:live-bridge:status`, and `flowchain:no-secret:scan`. +- Added latency fields on bridge credit receipts: + `baseObservedAt`, `handoffWrittenAt`, `nodeIngestedAt`, `creditAppliedAt`, + `firstSpendableAt`, and `totalSeconds`. + +## Commands Run + +```powershell +cargo test --manifest-path crates/flowmemory-devnet/Cargo.toml +npm run flowchain:node:start +npm run flowchain:node:status +npm run flowchain:node:smoke +npm run flowchain:no-secret:scan +``` + +Results: + +- `cargo test --manifest-path crates/flowmemory-devnet/Cargo.toml`: passed, + 28 Rust integration tests. +- `npm run flowchain:node:start`: passed. Report: + `devnet/local/live-l1-bridge-intake/node-start-report.json`. +- `npm run flowchain:node:status`: passed. Persisted node status reported + `status=running`, `nodeId=node:local:live-pilot`, chain id `31337`, and + `bridgeCredits=0`. +- `npm run flowchain:node:smoke`: passed. Report: + `devnet/local/node-smoke/production-node-smoke-report.json`. +- `npm run flowchain:no-secret:scan`: passed. Report: + `devnet/local/live-l1-bridge-intake/no-secret-scan-report.json`. + +## External Blocker + +No explicit live Base 8453 bridge runtime handoff exists in this worktree. +Checked: + +```text +services/bridge-relayer/out/ +devnet/local/live-base8453-pilot-runtime/ +devnet/local/ +``` + +The required live intake command is therefore not run to completion yet: + +```powershell +npm run flowchain:bridge:ingest -- -HandoffPath devnet/local/live-base8453-pilot-runtime/base8453-handoff-applied.json +npm run flowchain:wallet:transfer:e2e +npm run flowchain:restart:verify +npm run flowchain:live-bridge:status +``` + +The handoff must be a real `flowmemory.bridge_runtime_handoff.v0` artifact with +`productionReady=true`, `localOnly=false`, Base source chain id `8453`, and +satisfied 12-confirmation evidence. Fixture or mock data should not be relabeled +as live to satisfy this gate. + +## Current Main State + +The main runtime state is: + +```text +devnet/local/state.json +``` + +Current status after unbounded node start: + +- Node is running and producing blocks. +- `bridgeCredits=0`. +- `bridgeCreditReceipts=0`. +- `bridgeReplayKeys=0`. + +This matches the verified failure fact that bridge proof output is not currently +landing in main node state because the live handoff artifact is absent. + +## Next Command + +After the bridge proof path writes the real live handoff file, run: + +```powershell +npm run flowchain:bridge:ingest -- -HandoffPath +npm run flowchain:wallet:transfer:e2e +npm run flowchain:restart:verify +npm run flowchain:live-bridge:status +npm run flowchain:no-secret:scan +git diff --check +``` diff --git a/docs/agent-runs/production-l1-runtime/MEMPOOL_PROOF.md b/docs/agent-runs/production-l1-runtime/MEMPOOL_PROOF.md new file mode 100644 index 00000000..df998ab8 --- /dev/null +++ b/docs/agent-runs/production-l1-runtime/MEMPOOL_PROOF.md @@ -0,0 +1,38 @@ +# Mempool Proof + +## Behavior + +- Pending transactions are persisted in `state.pendingTxs`. +- The mempool is bounded at 1024 transactions. +- Ordering is deterministic by the runtime queue order and block production path. +- Duplicate tx ids are rejected. +- Signed transaction replay keys are rejected. +- Same signer nonce conflicts are rejected unless the next expected nonce is supplied. +- Pending transactions survive restart because they are part of the state file. +- Included transactions are removed from pending state and indexed in `transactions`, `receipts`, `consumedTxs`, and replay maps. + +## Query Surface + +Operators and RPC/dashboard agents can query: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- list-mempool +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- node-status +``` + +`node-status` exposes `pendingTxs`, `maxMempoolTxs`, `accountNonces`, `consumedTxs`, and `bridgeReplayKeys`. + +## Smoke Evidence + +The node smoke accepted 26 transactions, produced receipts for all 26, and ended with: + +```text +pendingTxs: 0 +maxMempoolTxs: 1024 +accountNonces: 1 +consumedTxs: 26 +receipts: 26 +events: 26 +``` + +The signed transaction replay was rejected after inclusion with `duplicate-tx-id`. diff --git a/docs/agent-runs/production-l1-runtime/NODE_COMMANDS.md b/docs/agent-runs/production-l1-runtime/NODE_COMMANDS.md new file mode 100644 index 00000000..34c5076a --- /dev/null +++ b/docs/agent-runs/production-l1-runtime/NODE_COMMANDS.md @@ -0,0 +1,47 @@ +# Production L1 Runtime Node Commands + +All commands run from the repository root. + +## Root npm aliases + +```powershell +npm run flowchain:node +npm run flowchain:node:stop +npm run flowchain:node:status +npm run flowchain:node:restart +npm run flowchain:tx -- --tx-file devnet/local/node-smoke/tx/signed-register-agent.json +npm run flowchain:node:smoke +``` + +## Rust CLI commands + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- node --node-id node:local:one --block-ms 1000 +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- node-stop +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- node-status +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- node-restart --node-id node:local:one --max-blocks 1 +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- tick +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- submit-tx --tx-file +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- list-mempool +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query-block --id 1 +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query-tx --id +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query-receipt --id +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query-account --id +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query-token --id +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query-pool --id +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query-bridge-credit --id +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- query-finality --id +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- export-state --out devnet/local/state-snapshot.json +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- import-state --from devnet/local/state-snapshot.json +``` + +Use `--state ` and `--node-dir ` before the command to isolate a runtime data directory. + +## Node process contract + +- Start: `node` opens or creates the local data directory, writes status, and produces blocks until stopped or until `--max-blocks` is reached. +- Stop: `node-stop` writes a stop file and updates stopped status. +- Restart: `node-restart` performs a stop then starts from the same state path and node directory. +- Status: `node-status` prints chain id, height, latest hash, finalized height, state root, mempool size, log path, and last error. +- Log path: `/node.log.jsonl`. +- Status path: `/status.json`. diff --git a/docs/agent-runs/production-l1-runtime/NOTES.md b/docs/agent-runs/production-l1-runtime/NOTES.md new file mode 100644 index 00000000..cb99c93b --- /dev/null +++ b/docs/agent-runs/production-l1-runtime/NOTES.md @@ -0,0 +1,25 @@ +# Production L1 Runtime Notes + +## Current Observations + +- Work started from branch `agent/production-l1-runtime`. +- The repo already exposes the requested root npm aliases for node, status, stop, tx, faucet, and node smoke. +- The task remains bounded to private/local L1 behavior; no production mainnet, public validator, tokenomics, audited bridge, or custody claims. +- The local branch is behind `origin/main` by two real-value pilot bridge/contract commits. Those commits are outside this task's allowed edit scope except for unrelated package scripts, so this runtime work remains scoped and should merge/rebase before PR finalization. + +## Current Runtime Map + +- CLI commands now include `node`, `node-stop`, `node-status`, `node-restart`, `tick`, `submit-tx`, `list-mempool`, query shortcuts, `export-state`, `import-state`, and `node-smoke`. +- Default persistent state path is `devnet/local/state.json`; node operator files live under `devnet/local/node/`; smoke proof files live under `devnet/local/node-smoke/`. +- Runtime status is written as `node/status.json`; append-only node activity is written as `node/node.log.jsonl`. +- Transaction intake supports direct CLI submission and node file inbox ingestion. Signed local transaction envelopes are validated against the crypto fixture shape without editing `crypto/`. +- Current block format stores number, parent hash, logical time, tx ids, receipts, events, state root, receipt root, event root, finalized height, and block hash. +- Current transaction surface includes local object lifecycle payloads plus local balance transfer, token launch/transfer/mint, DEX pool/liquidity/swap flows, Base 8453 bridge credit application, and withdrawal intent recording. +- Runtime gaps that remain intentionally local/private: no production consensus, no public peer networking, no gas market, no custody system, no audited bridge security claim, and no production withdrawal broadcast. + +## Implementation Notes + +- State roots include application state maps and bridge/withdrawal state, while mempool/query indexes are kept out of the application state root so rejected or pending txs do not mutate canonical app state. +- Signed envelope replay is tracked by tx id, nonce, and replay key. Locally authorized txs keep deterministic ids and can be used for private smoke automation. +- Bridge credit execution requires `sourceChainId` 8453 and a one-time replay key before crediting local spendable test units or an existing local token asset. +- Handoff JSON is written both next to state and inside a `handoff/` directory for dashboard, indexer, verifier, and control-plane agents. diff --git a/docs/agent-runs/production-l1-runtime/PLAN.md b/docs/agent-runs/production-l1-runtime/PLAN.md new file mode 100644 index 00000000..b50b6869 --- /dev/null +++ b/docs/agent-runs/production-l1-runtime/PLAN.md @@ -0,0 +1,17 @@ +# Production L1 Runtime Plan + +## Scope + +- Extend the existing Rust devnet in `crates/flowmemory-devnet/`. +- Keep runtime outputs under `devnet/` and wrapper changes under `infra/scripts/flowchain-*.ps1`. +- Keep crypto fixtures and schemas read-only. +- Do not create a second runtime. + +## Phases + +1. Map the current CLI, state files, transaction types, block format, and protocol gaps. Done in `NOTES.md`. +2. Add persistent node config, status, logs, and bounded or long-running start behavior. Done in the Rust devnet CLI and node wrappers. +3. Add signed envelope intake, validation, mempool persistence, duplicate/replay rejection, and rejection evidence. Done through `submit-tx`, file inbox ingestion, and runtime validation. +4. Produce deterministic blocks with receipts, events, roots, and query indexes. Done through `node`, `tick`, `run-block`, and stored receipt/event indexes. +5. Add restart, export/import, and node smoke proof covering accepted and rejected transactions. Done through `node-restart`, `export-state`, `import-state`, and `npm run flowchain:node:smoke`. +6. Update wrapper scripts, docs, handoff files, and acceptance evidence. Done in the production runtime run docs and `docs/LOCAL_DEVNET.md`. diff --git a/docs/agent-runs/production-l1-runtime/RESTART_PROOF.md b/docs/agent-runs/production-l1-runtime/RESTART_PROOF.md new file mode 100644 index 00000000..92360266 --- /dev/null +++ b/docs/agent-runs/production-l1-runtime/RESTART_PROOF.md @@ -0,0 +1,53 @@ +# Restart Proof + +## Behavior + +The runtime restarts from disk through the same state file and node directory: + +```powershell +npm run flowchain:node:restart +``` + +or: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- node-restart --max-blocks 1 +``` + +Persisted recovery covers: + +- Latest height. +- Latest hash. +- Finalized height. +- State root. +- Account balances. +- Pending mempool transactions. +- Transactions and receipts. +- Events. +- Bridge observations, credits, and replay keys. +- Withdrawal intents. + +## Smoke Evidence + +`npm run flowchain:node:smoke` stopped and restarted the node, then queried the signed transaction receipt after restart. + +State root before and after the restart leg stayed: + +```text +0x3e362fa09ddd18626c6213f49863531c7e93cd7c13708894aa19ff9d700201e8 +``` + +The latest hash changed because the restart leg intentionally produced one additional empty persisted block: + +```text +beforeRestartLatestHash: 0xe42af946fbe1ebf485333fa683e6ec724dbe7931e66506408808d3bf33cab1a9 +afterRestartLatestHash: 0xd16b721acf982ecec33b49f35c4b031d1cac47c6526ae28daa336e6f1b8716cd +``` + +Export/import then preserved both the final state root and final latest hash: + +```text +importedStateRoot: 0x3e362fa09ddd18626c6213f49863531c7e93cd7c13708894aa19ff9d700201e8 +importedLatestHash: 0xd16b721acf982ecec33b49f35c4b031d1cac47c6526ae28daa336e6f1b8716cd +preserved: true +``` diff --git a/docs/agent-runs/production-l1-runtime/TX_LIFECYCLE_PROOF.md b/docs/agent-runs/production-l1-runtime/TX_LIFECYCLE_PROOF.md new file mode 100644 index 00000000..36ef1af9 --- /dev/null +++ b/docs/agent-runs/production-l1-runtime/TX_LIFECYCLE_PROOF.md @@ -0,0 +1,43 @@ +# Transaction Lifecycle Proof + +## Intake + +The runtime accepts transactions through: + +- `submit-tx --tx-file ` for direct local CLI submission. +- The node inbox path under `/tx/` for a running node. +- `--direct` for synchronous smoke and test automation. + +Supported input shapes are signed local transaction envelopes, a single `tx`, or a batch under `txs`. + +## Validation + +Signed envelopes are validated before mempool insertion: + +- Schema shape and transaction payload are parsed. +- Chain id must match the runtime chain id, default `31337`. +- Payload hash, domain separator, envelope hash, digest, signer role, public key, and secp256k1 signature are checked against the local crypto envelope rules. +- Nonce must be the next nonce for the signer; stale and future nonce conflicts are rejected. +- Tx id, replay key, and consumed transaction indexes reject duplicate or replayed transactions. +- A preflight execution check rejects insufficient balances, unknown payloads, duplicate object ids, wrong bridge source chain, and bridge replay keys before canonical block inclusion. +- The mempool is bounded at 1024 pending transactions. + +## Inclusion + +Block production selects pending transactions deterministically, executes them, stores a `StoredTransaction`, writes a `StoredReceipt`, emits one event per included transaction, updates indexes, and removes included txs from the mempool. + +## Smoke Evidence + +`npm run flowchain:node:smoke` submitted a signed transaction from `crypto/fixtures/local-transaction-vectors.json`, accepted it exactly once as: + +```text +0xfba94617ac6fbae608393c67570280d7123b27dabb0c1f31427808ad955a7c46 +``` + +The same tx was queried after inclusion and after restart. A replay submit was rejected with: + +```text +duplicate-tx-id +``` + +The full evidence record is `devnet/local/node-smoke/production-node-smoke-report.json`. diff --git a/infra/scripts/flowchain-bridge-ingest.ps1 b/infra/scripts/flowchain-bridge-ingest.ps1 new file mode 100644 index 00000000..e100683a --- /dev/null +++ b/infra/scripts/flowchain-bridge-ingest.ps1 @@ -0,0 +1,168 @@ +param( + [Parameter(Mandatory = $true)] + [string] $HandoffPath, + + [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node", + [string] $OutDir = "devnet/local/live-l1-bridge-intake", + [int] $TimeoutSeconds = 60 +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +function Read-JsonFile { + param([Parameter(Mandatory = $true)][string] $Path) + return Get-Content -Raw -LiteralPath $Path | ConvertFrom-Json +} + +function Get-ObjectPropertyCount { + param([object] $Value) + if ($null -eq $Value -or $null -eq $Value.PSObject) { + return 0 + } + return @($Value.PSObject.Properties).Count +} + +function Get-FirstPropertyValue { + param([object] $Value) + $properties = @($Value.PSObject.Properties) + if ($properties.Count -lt 1) { + return $null + } + return $properties[0].Value +} + +function Convert-FlowChainTimestampSeconds { + param([Parameter(Mandatory = $true)][object] $Value) + + $text = [string] $Value + $numeric = 0.0 + if ([double]::TryParse($text, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref] $numeric)) { + return $numeric + } + + return ([DateTimeOffset]::Parse($text, [System.Globalization.CultureInfo]::InvariantCulture)).ToUnixTimeSeconds() +} + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot -Purpose "bridge-ingest" | 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) +$handoffFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $HandoffPath) +$outFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $OutDir) +New-Item -ItemType Directory -Force -Path $outFullDir | Out-Null + +if (-not (Test-Path -LiteralPath $handoffFullPath)) { + throw "Bridge handoff file does not exist: $handoffFullPath" +} + +$statusPath = Join-Path $nodeFullDir "status.json" +if (-not (Test-Path -LiteralPath $statusPath)) { + throw "Node status is missing. Start the node with npm run flowchain:node:start first." +} +$status = Read-JsonFile -Path $statusPath +if ($status.status -ne "running") { + throw "Node is not running; status is '$($status.status)'." +} + +$beforeState = if (Test-Path -LiteralPath $stateFullPath) { Read-JsonFile -Path $stateFullPath } else { $null } +$beforeCredits = if ($null -eq $beforeState) { 0 } else { Get-ObjectPropertyCount -Value $beforeState.bridgeCredits } + +$handoff = Read-JsonFile -Path $handoffFullPath +if ($handoff.schema -ne "flowmemory.bridge_runtime_handoff.v0") { + throw "Unsupported bridge handoff schema: $($handoff.schema)" +} +if ($handoff.productionReady -ne $true -or $handoff.localOnly -ne $false) { + throw "Live intake requires productionReady=true and localOnly=false handoff flags." +} + +$ingestOutput = (& cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $stateFullPath --node-dir $nodeFullDir bridge-ingest --handoff $handoffFullPath --authorized-by operator:bridge:live-pilot --require-live 2>&1) -join [Environment]::NewLine +if ($LASTEXITCODE -ne 0) { + throw "Bridge ingest command failed.`n$ingestOutput" +} +$jsonStart = $ingestOutput.IndexOf("{") +$jsonEnd = $ingestOutput.LastIndexOf("}") +if ($jsonStart -lt 0 -or $jsonEnd -lt $jsonStart) { + throw "Bridge ingest command did not emit JSON.`n$ingestOutput" +} +$ingest = $ingestOutput.Substring($jsonStart, $jsonEnd - $jsonStart + 1) | ConvertFrom-Json +if (@($ingest.queued.queued).Count -lt 1) { + throw "Bridge ingest did not queue any runtime transactions." +} + +$deadline = (Get-Date).AddSeconds($TimeoutSeconds) +$afterState = $null +$firstCredit = $null +$receipt = $null +while ((Get-Date) -lt $deadline) { + if (Test-Path -LiteralPath $stateFullPath) { + try { + $candidate = Read-JsonFile -Path $stateFullPath + if ((Get-ObjectPropertyCount -Value $candidate.bridgeCredits) -gt $beforeCredits) { + $afterState = $candidate + $firstCredit = Get-FirstPropertyValue -Value $candidate.bridgeCredits + if ($null -ne $firstCredit) { + $receiptProp = $candidate.bridgeCreditReceipts.PSObject.Properties[$firstCredit.creditId] + if ($null -ne $receiptProp) { + $receipt = $receiptProp.Value + break + } + } + } + } + catch { + } + } + Start-Sleep -Milliseconds 500 +} + +if ($null -eq $afterState -or $null -eq $firstCredit -or $null -eq $receipt) { + throw "Timed out waiting for bridge credit application in $stateFullPath." +} + +$latencyTotalSeconds = $receipt.latency.totalSeconds +if ($null -eq $latencyTotalSeconds) { + $latencyTotalSeconds = [int] ((Convert-FlowChainTimestampSeconds -Value $receipt.latency.firstSpendableAt) - (Convert-FlowChainTimestampSeconds -Value $receipt.latency.handoffWrittenAt)) +} +if ([int] $latencyTotalSeconds -gt 60) { + throw "Bridge intake exceeded 60 seconds after handoff: $latencyTotalSeconds." +} + +$balance = $afterState.localTestUnitBalances.PSObject.Properties[$firstCredit.recipientAccountId].Value +if ($null -eq $balance -or [UInt64] $balance.units -lt [UInt64] $firstCredit.amountUnits) { + throw "Credited balance is missing or lower than credit amount." +} + +$report = [ordered]@{ + schema = "flowchain.live_l1.bridge_ingest_report.v0" + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + handoffPath = $handoffFullPath + statePath = $stateFullPath + nodeDir = $nodeFullDir + nodeStatus = $status + queued = $ingest.queued + bridgeCreditsBefore = $beforeCredits + bridgeCreditsAfter = Get-ObjectPropertyCount -Value $afterState.bridgeCredits + bridgeCreditReceipts = Get-ObjectPropertyCount -Value $afterState.bridgeCreditReceipts + bridgeReplayKeys = Get-ObjectPropertyCount -Value $afterState.bridgeReplayKeys + creditedAccount = $firstCredit.recipientAccountId + creditedBalance = $balance.units + creditId = $firstCredit.creditId + receiptId = $receipt.receiptId + latency = [ordered]@{ + baseObservedAt = $receipt.latency.baseObservedAt + handoffWrittenAt = $receipt.latency.handoffWrittenAt + nodeIngestedAt = $receipt.latency.nodeIngestedAt + creditAppliedAt = $receipt.latency.creditAppliedAt + firstSpendableAt = $receipt.latency.firstSpendableAt + totalSeconds = [int] $latencyTotalSeconds + } + passed = $true +} + +$reportPath = Join-Path $outFullDir "bridge-ingest-report.json" +Write-FlowChainJson -Path $reportPath -Value $report -Depth 24 +$report | ConvertTo-Json -Depth 24 diff --git a/infra/scripts/flowchain-live-bridge-status.ps1 b/infra/scripts/flowchain-live-bridge-status.ps1 new file mode 100644 index 00000000..90be382c --- /dev/null +++ b/infra/scripts/flowchain-live-bridge-status.ps1 @@ -0,0 +1,110 @@ +param( + [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node", + [string] $OutDir = "devnet/local/live-l1-bridge-intake" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +function Read-JsonFile { + param([Parameter(Mandatory = $true)][string] $Path) + return Get-Content -Raw -LiteralPath $Path | ConvertFrom-Json +} + +function Get-ObjectPropertyCount { + param([object] $Value) + if ($null -eq $Value -or $null -eq $Value.PSObject) { + return 0 + } + return @($Value.PSObject.Properties).Count +} + +function Get-FirstPropertyValue { + param([object] $Value) + $properties = @($Value.PSObject.Properties) + if ($properties.Count -lt 1) { + return $null + } + return $properties[0].Value +} + +$repoRoot = Set-FlowChainRepoRoot +$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) +New-Item -ItemType Directory -Force -Path $outFullDir | Out-Null + +$statusPath = Join-Path $nodeFullDir "status.json" +if (-not (Test-Path -LiteralPath $statusPath)) { + throw "Node status is missing." +} +if (-not (Test-Path -LiteralPath $stateFullPath)) { + throw "State file is missing." +} + +$status = Read-JsonFile -Path $statusPath +$state = Read-JsonFile -Path $stateFullPath +$credit = Get-FirstPropertyValue -Value $state.bridgeCredits +if ($null -eq $credit) { + throw "No bridge credits exist in state." +} +$receipt = $state.bridgeCreditReceipts.PSObject.Properties[$credit.creditId].Value +$balance = $state.localTestUnitBalances.PSObject.Properties[$credit.recipientAccountId].Value +$transferReportPath = Join-Path $outFullDir "wallet-transfer-report.json" +$restartReportPath = Join-Path $outFullDir "restart-verify-report.json" +$ingestReportPath = Join-Path $outFullDir "bridge-ingest-report.json" +$transferReport = if (Test-Path -LiteralPath $transferReportPath) { Read-JsonFile -Path $transferReportPath } else { $null } +$restartReport = if (Test-Path -LiteralPath $restartReportPath) { Read-JsonFile -Path $restartReportPath } else { $null } +$ingestReport = if (Test-Path -LiteralPath $ingestReportPath) { Read-JsonFile -Path $ingestReportPath } else { $null } + +$checks = [ordered]@{ + nodeRunning = ($status.status -eq "running") + bridgeCreditsPositive = ((Get-ObjectPropertyCount -Value $state.bridgeCredits) -gt 0) + bridgeCreditReceiptsPositive = ((Get-ObjectPropertyCount -Value $state.bridgeCreditReceipts) -gt 0) + bridgeReplayKeysPositive = ((Get-ObjectPropertyCount -Value $state.bridgeReplayKeys) -gt 0) + creditedBalanceExists = ($null -ne $balance -and [UInt64] $balance.units -gt 0) + creditedBalanceTransferred = ($null -ne $transferReport -and $transferReport.passed -eq $true) + exportImportPreserved = ($null -ne $restartReport -and $restartReport.exportImportPreserved -eq $true) + liveHandoffApplied = ($credit.productionReady -eq $true -and $credit.localOnly -eq $false) +} + +foreach ($entry in $checks.GetEnumerator()) { + if (-not $entry.Value) { + throw "Live bridge status check failed: $($entry.Key)" + } +} + +$report = [ordered]@{ + schema = "flowchain.live_l1.bridge_status_report.v0" + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + statePath = $stateFullPath + nodeDir = $nodeFullDir + checks = $checks + node = [ordered]@{ + status = $status.status + nodeId = $status.nodeId + pid = $status.pid + latestHeight = $status.latestHeight + stateRoot = $status.stateRoot + } + bridge = [ordered]@{ + bridgeCredits = Get-ObjectPropertyCount -Value $state.bridgeCredits + bridgeCreditReceipts = Get-ObjectPropertyCount -Value $state.bridgeCreditReceipts + bridgeReplayKeys = Get-ObjectPropertyCount -Value $state.bridgeReplayKeys + creditId = $credit.creditId + receiptId = $receipt.receiptId + creditedAccount = $credit.recipientAccountId + creditedBalance = $balance.units + latency = if ($null -ne $ingestReport) { $ingestReport.latency } else { $receipt.latency } + } + transfer = $transferReport + restart = $restartReport + passed = $true +} + +$reportPath = Join-Path $outFullDir "LIVE_NODE_BRIDGE_INTAKE_STATUS.json" +Write-FlowChainJson -Path $reportPath -Value $report -Depth 24 +$report | ConvertTo-Json -Depth 24 diff --git a/infra/scripts/flowchain-no-secret-scan.ps1 b/infra/scripts/flowchain-no-secret-scan.ps1 new file mode 100644 index 00000000..c9e5e154 --- /dev/null +++ b/infra/scripts/flowchain-no-secret-scan.ps1 @@ -0,0 +1,26 @@ +param( + [string] $Path = "devnet/local/live-l1-bridge-intake" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +$fullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $Path) + +Assert-FlowChainNoSecretFiles -Path $fullPath + +$report = [ordered]@{ + schema = "flowchain.no_secret_scan.v0" + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + path = $fullPath + passed = $true +} + +if ((Get-Item -LiteralPath $fullPath).PSIsContainer) { + Write-FlowChainJson -Path (Join-Path $fullPath "no-secret-scan-report.json") -Value $report -Depth 8 +} + +$report | ConvertTo-Json -Depth 8 diff --git a/infra/scripts/flowchain-node-restart.ps1 b/infra/scripts/flowchain-node-restart.ps1 new file mode 100644 index 00000000..610bdd39 --- /dev/null +++ b/infra/scripts/flowchain-node-restart.ps1 @@ -0,0 +1,44 @@ +param( + [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node", + [string] $NodeId = "node:local:alpha", + [int] $BlockMs = 1000, + [int] $MaxBlocks = 1, + [string] $PeerConfig = "" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot | Out-Null +$stateFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $StatePath) +$nodeFullDir = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $NodeDir) + +$arguments = @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateFullPath, + "--node-dir", + $nodeFullDir, + "node-restart", + "--node-id", + $NodeId, + "--block-ms", + "$BlockMs", + "--max-blocks", + "$MaxBlocks" +) + +if (-not [string]::IsNullOrWhiteSpace($PeerConfig)) { + $peerConfigFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $PeerConfig) + $arguments += @("--peer-config", $peerConfigFullPath) +} + +Invoke-FlowChainCommand -Label "Restart FlowChain private/local node" -FilePath "cargo" -ArgumentList $arguments + diff --git a/infra/scripts/flowchain-node-smoke.ps1 b/infra/scripts/flowchain-node-smoke.ps1 index 2004e8f4..311c469f 100644 --- a/infra/scripts/flowchain-node-smoke.ps1 +++ b/infra/scripts/flowchain-node-smoke.ps1 @@ -18,10 +18,84 @@ New-Item -ItemType Directory -Force -Path $smokeFullDir | Out-Null $statePath = Join-Path $smokeFullDir "state.json" $nodeDir = Join-Path $smokeFullDir "node" +$txDir = Join-Path $smokeFullDir "tx" $snapshotPath = Join-Path $smokeFullDir "state-snapshot.json" $importedStatePath = Join-Path $smokeFullDir "imported-state.json" $stdoutPath = Join-Path $smokeFullDir "node.stdout.jsonl" $stderrPath = Join-Path $smokeFullDir "node.stderr.log" +$signedTxPath = Join-Path $txDir "signed-register-agent.json" +$batchTxPath = Join-Path $txDir "runtime-batch.json" +New-Item -ItemType Directory -Force -Path $txDir | Out-Null + +$fixture = Get-Content -Raw -LiteralPath (Join-Path $repoRoot "crypto/fixtures/local-transaction-vectors.json") | ConvertFrom-Json +$signedEnvelope = $fixture.positive[0].envelope +$signedTxId = $signedEnvelope.envelopeId +Write-FlowChainJson -Path $signedTxPath -Value ([ordered]@{ + schema = "flowmemory.local_devnet.signed_tx_submission.v0" + envelope = $signedEnvelope +}) + +$batchTxs = @() +for ($i = 1; $i -le 20; $i++) { + $batchTxs += [ordered]@{ + type = "CreateLocalTestUnitBalance" + accountId = "local-account:node-smoke:$i" + owner = "operator:node-smoke" + } +} +$batchTxs += [ordered]@{ + type = "FaucetLocalTestUnits" + faucetRecordId = "faucet:node-smoke:001" + accountId = "local-account:node-smoke:1" + recipient = "operator:node-smoke" + amountUnits = 1000 + reason = "node-smoke-balance-update" +} +$batchTxs += [ordered]@{ + type = "CreateLocalTestUnitBalance" + accountId = "local-account:bridge:bob" + owner = "operator:bridge:bob" +} +$batchTxs += [ordered]@{ + type = "ApplyBridgeCredit" + creditId = "bridge-credit:node-smoke:001" + observationId = "bridge-observation:node-smoke:001" + depositId = "bridge-deposit:node-smoke:001" + replayKey = "bridge-replay:base8453:node-smoke:001" + sourceChainId = 8453 + sourceContract = "0x1111111111111111111111111111111111111111" + sourceTxHash = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + sourceLogIndex = 0 + token = "0x3333333333333333333333333333333333333333" + assetId = "asset:flowchain-local-test-unit" + recipientAccountId = "local-account:bridge:alice" + amountUnits = 75 + verifier = "bridge-verifier:node-smoke" + evidenceHash = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +} +$batchTxs += [ordered]@{ + type = "TransferLocalTestUnits" + transferId = "transfer:bridge:node-smoke:001" + fromAccountId = "local-account:bridge:alice" + toAccountId = "local-account:bridge:bob" + amountUnits = 25 + memo = "bridge-credit-spend-proof" +} +$batchTxs += [ordered]@{ + type = "RequestWithdrawal" + withdrawalIntentId = "withdrawal-intent:node-smoke:001" + creditId = "bridge-credit:node-smoke:001" + accountId = "local-account:bridge:alice" + assetId = "asset:flowchain-local-test-unit" + amountUnits = 10 + destinationChainId = 8453 + baseRecipient = "0x4444444444444444444444444444444444444444" + memo = "test-mode-withdrawal-intent" +} +Write-FlowChainJson -Path $batchTxPath -Value ([ordered]@{ + schema = "flowmemory.local_devnet.runtime_batch.v0" + txs = $batchTxs +}) $nodeArgs = @( "run", @@ -36,38 +110,27 @@ $nodeArgs = @( "--node-id", "node:smoke:one", "--block-ms", - "250", + "500", "--max-blocks", - "10" + "20" ) $process = Start-Process -FilePath "cargo" -ArgumentList (Join-FlowChainProcessArguments -ArgumentList $nodeArgs) -WorkingDirectory $repoRoot -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -Start-Sleep -Milliseconds 700 +Start-Sleep -Milliseconds 900 -Invoke-FlowChainCommand -Label "Submit locally authorized faucet transaction to running node" -FilePath "cargo" -ArgumentList @( - "run", - "--manifest-path", - "crates/flowmemory-devnet/Cargo.toml", - "--", - "--state", - $statePath, - "--node-dir", - $nodeDir, - "faucet", - "--account", - "local-account:node-smoke", - "--amount", - "42", - "--reason", - "one-node-smoke", - "--authorized-by", - "local-smoke-operator" -) +$signedSubmit = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $statePath --node-dir $nodeDir submit-tx --tx-file $signedTxPath | ConvertFrom-Json +if ($LASTEXITCODE -ne 0 -or $signedSubmit.queued.Count -ne 1) { + throw "Signed transaction submit failed." +} +$batchSubmit = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $statePath --node-dir $nodeDir submit-tx --tx-file $batchTxPath --authorized-by local-node-smoke-operator | ConvertFrom-Json +if ($LASTEXITCODE -ne 0 -or $batchSubmit.queued.Count -lt 24) { + throw "Runtime batch submit failed or accepted fewer transactions than expected." +} -if (-not $process.WaitForExit(30000)) { +if (-not $process.WaitForExit(45000)) { & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $statePath --node-dir $nodeDir node-stop | Out-Null $process.Kill() - throw "One-node smoke runtime did not stop after bounded 10-block run." + throw "One-node smoke runtime did not stop after bounded 20-block run." } $process.Refresh() @@ -80,11 +143,25 @@ $summary = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- -- if ($LASTEXITCODE -ne 0) { throw "node-status failed after one-node smoke." } -if ($summary.state.blocks -lt 10) { - throw "Expected one-node smoke to produce at least 10 blocks, got $($summary.state.blocks)." +if ($summary.state.blocks -lt 20) { + throw "Expected one-node smoke to produce at least 20 blocks, got $($summary.state.blocks)." } -if ($summary.state.localBalances -lt 1 -or $summary.state.faucetRecords -lt 1) { - throw "Expected locally authorized faucet transaction to be included in one-node smoke." +if ($summary.state.transactions -lt 25 -or $summary.state.receipts -lt 25) { + throw "Expected at least 25 accepted/queryable transactions and receipts." +} +if ($summary.state.bridgeCredits -lt 1 -or $summary.state.withdrawalIntents -lt 1) { + throw "Expected bridge credit and withdrawal intent state in one-node smoke." +} + +$signedQuery = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $statePath query-tx --id $signedTxId | ConvertFrom-Json +$signedReceipt = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $statePath query-receipt --id $signedTxId | ConvertFrom-Json +$bridgeCredit = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $statePath query-bridge-credit --id "bridge-credit:node-smoke:001" | ConvertFrom-Json +$bridgeRecipient = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $statePath query-account --id "local-account:bridge:bob" | ConvertFrom-Json +if ($signedQuery.transaction.status -ne "applied" -or $signedReceipt.receipt.status -ne "applied") { + throw "Signed transaction was not queryable as applied." +} +if ($bridgeCredit.credit.status -ne "applied" -or $bridgeRecipient.localTestUnitBalance.units -ne 25) { + throw "Bridge credit spend proof did not produce expected queryable state." } Invoke-FlowChainCommand -Label "Restart node for persistence check" -FilePath "cargo" -ArgumentList @( @@ -109,11 +186,17 @@ $restartedSummary = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.t if ($LASTEXITCODE -ne 0) { throw "node-status failed after persistence restart." } -if ($restartedSummary.state.blocks -lt 11) { +if ($restartedSummary.state.blocks -lt 21) { throw "Expected restart to preserve state and add a block." } -if ($restartedSummary.state.localBalances -lt 1 -or $restartedSummary.state.faucetRecords -lt 1) { - throw "Expected local balance state to survive restart." +$signedReceiptAfterRestart = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $statePath query-receipt --id $signedTxId | ConvertFrom-Json +if ($signedReceiptAfterRestart.receipt.status -ne "applied") { + throw "Receipt query failed after restart." +} + +$replay = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $statePath submit-tx --tx-file $signedTxPath --direct | ConvertFrom-Json +if ($LASTEXITCODE -ne 0 -or $replay.rejected.Count -lt 1) { + throw "Expected signed replay to be rejected." } Invoke-FlowChainCommand -Label "Export runtime state snapshot" -FilePath "cargo" -ArgumentList @( @@ -148,26 +231,66 @@ $imported = & cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- - if ($LASTEXITCODE -ne 0) { throw "Failed to inspect imported smoke state." } -if ($original.stateRoot -ne $imported.stateRoot) { - throw "Export/import state roots differ: $($original.stateRoot) vs $($imported.stateRoot)" +if ($original.stateRoot -ne $imported.stateRoot -or $original.latestHash -ne $imported.latestHash) { + throw "Export/import roots or latest hashes differ." } -$reportPath = Join-Path $smokeFullDir "one-node-smoke-report.json" +$stateJson = Get-Content -Raw -LiteralPath $statePath | ConvertFrom-Json +$appliedReceiptIds = @($stateJson.receipts.PSObject.Properties | Where-Object { $_.Value.status -eq "applied" } | ForEach-Object { $_.Name }) +$allTxIds = @($signedSubmit.queued + $batchSubmit.queued) +$reportPath = Join-Path $smokeFullDir "production-node-smoke-report.json" $report = [ordered]@{ - schema = "flowchain.private_testnet.one_node_smoke.v0" + schema = "flowchain.private_testnet.production_node_smoke.v0" generatedAt = (Get-Date).ToUniversalTime().ToString("o") + commandsRun = @( + "npm run flowchain:node:smoke", + "flowmemory-devnet node --max-blocks 20", + "flowmemory-devnet submit-tx signed", + "flowmemory-devnet submit-tx local batch", + "flowmemory-devnet query-tx", + "flowmemory-devnet query-receipt", + "flowmemory-devnet query-bridge-credit", + "flowmemory-devnet export-state", + "flowmemory-devnet import-state" + ) statePath = $statePath nodeDir = $nodeDir - blocksAfterRestart = $restartedSummary.state.blocks - locallyAuthorizedTxIncluded = $true - stateSurvivedRestart = $true - exportImportStateRoot = $original.stateRoot - lanMode = "not exposed; static local-file peers only" + blockCount = $restartedSummary.state.blocks + txIds = $allTxIds + receiptIds = $appliedReceiptIds + stateRoot = $original.stateRoot + latestHash = $original.latestHash + restartProof = [ordered]@{ + beforeRestartStateRoot = $summary.state.stateRoot + afterRestartStateRoot = $restartedSummary.state.stateRoot + beforeRestartLatestHash = $summary.state.parentHash + afterRestartLatestHash = $restartedSummary.state.parentHash + receiptsQueryableAfterRestart = $true + } + signedSubmit = [ordered]@{ + txId = $signedTxId + acceptedOnce = $true + replayRejected = $true + replayReason = $replay.rejected[0].reason + } + bridgeCreditSpend = [ordered]@{ + creditId = "bridge-credit:node-smoke:001" + recipientCanSpend = $true + bobBalance = $bridgeRecipient.localTestUnitBalance.units + withdrawalIntentRecorded = $true + } + exportImportProof = [ordered]@{ + importedStateRoot = $imported.stateRoot + importedLatestHash = $imported.latestHash + preserved = $true + } + failureDetails = @() } Write-FlowChainJson -Path $reportPath -Value $report Write-Host "" Write-Host "FlowChain one-node runtime smoke passed." Write-Host "Blocks after restart: $($restartedSummary.state.blocks)" +Write-Host "Accepted tx IDs: $($allTxIds.Count)" Write-Host "State root: $($original.stateRoot)" Write-Host "Report: $reportPath" diff --git a/infra/scripts/flowchain-node-start.ps1 b/infra/scripts/flowchain-node-start.ps1 new file mode 100644 index 00000000..a1afe007 --- /dev/null +++ b/infra/scripts/flowchain-node-start.ps1 @@ -0,0 +1,131 @@ +param( + [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node", + [string] $NodeId = "node:local:live-pilot", + [int] $BlockMs = 1000, + [int] $MaxBlocks = 0, + [string] $PeerConfig = "", + [string] $OutDir = "devnet/local/live-l1-bridge-intake" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +function Test-FlowChainPidRunning { + param([object] $Status) + + if ($null -eq $Status -or $null -eq $Status.pid) { + return $false + } + + try { + $process = Get-Process -Id ([int] $Status.pid) -ErrorAction Stop + return $null -ne $process + } + catch { + return $false + } +} + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot -Purpose "live-node" | 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) + +New-Item -ItemType Directory -Force -Path $nodeFullDir | Out-Null +New-Item -ItemType Directory -Force -Path $outFullDir | Out-Null + +$statusPath = Join-Path $nodeFullDir "status.json" +if (Test-Path -LiteralPath $statusPath) { + $existingStatus = Get-Content -Raw -LiteralPath $statusPath | ConvertFrom-Json + if ($existingStatus.status -eq "running" -and (Test-FlowChainPidRunning -Status $existingStatus)) { + $report = [ordered]@{ + schema = "flowchain.live_l1.node_start.v0" + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + reusedRunningNode = $true + status = $existingStatus + statePath = $stateFullPath + nodeDir = $nodeFullDir + } + Write-FlowChainJson -Path (Join-Path $outFullDir "node-start-report.json") -Value $report + $report | ConvertTo-Json -Depth 20 + exit 0 + } +} + +$stopPath = Join-Path $nodeFullDir "stop" +if (Test-Path -LiteralPath $stopPath) { + Remove-Item -LiteralPath $stopPath -Force +} + +$stdoutPath = Join-Path $nodeFullDir "node.stdout.jsonl" +$stderrPath = Join-Path $nodeFullDir "node.stderr.log" + +$arguments = @( + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + $stateFullPath, + "--node-dir", + $nodeFullDir, + "node", + "--node-id", + $NodeId, + "--block-ms", + "$BlockMs" +) + +if ($MaxBlocks -gt 0) { + $arguments += @("--max-blocks", "$MaxBlocks") +} + +if (-not [string]::IsNullOrWhiteSpace($PeerConfig)) { + $peerConfigFullPath = Assert-FlowChainPathInsideRepo -RepoRoot $repoRoot -Path (Resolve-FlowChainPath -RepoRoot $repoRoot -Path $PeerConfig) + $arguments += @("--peer-config", $peerConfigFullPath) +} + +$process = Start-Process -FilePath "cargo" -ArgumentList (Join-FlowChainProcessArguments -ArgumentList $arguments) -WorkingDirectory $repoRoot -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath + +$deadline = (Get-Date).AddSeconds(30) +$status = $null +while ((Get-Date) -lt $deadline) { + if (Test-Path -LiteralPath $statusPath) { + try { + $status = Get-Content -Raw -LiteralPath $statusPath | ConvertFrom-Json + if ($status.status -eq "running" -and [int] $status.pid -eq $process.Id) { + break + } + } + catch { + } + } + Start-Sleep -Milliseconds 250 +} + +if ($null -eq $status -or $status.status -ne "running") { + if (-not $process.HasExited) { + $process.Kill() + } + throw "FlowChain node did not report running within 30 seconds. See $stderrPath" +} + +$report = [ordered]@{ + schema = "flowchain.live_l1.node_start.v0" + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + reusedRunningNode = $false + processId = $process.Id + statePath = $stateFullPath + nodeDir = $nodeFullDir + stdout = $stdoutPath + stderr = $stderrPath + unbounded = ($MaxBlocks -le 0) + status = $status +} + +Write-FlowChainJson -Path (Join-Path $outFullDir "node-start-report.json") -Value $report +$report | ConvertTo-Json -Depth 20 diff --git a/infra/scripts/flowchain-restart-verify.ps1 b/infra/scripts/flowchain-restart-verify.ps1 new file mode 100644 index 00000000..37c34fc1 --- /dev/null +++ b/infra/scripts/flowchain-restart-verify.ps1 @@ -0,0 +1,122 @@ +param( + [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node", + [string] $OutDir = "devnet/local/live-l1-bridge-intake", + [int] $BlockMs = 1000 +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +function Read-JsonFile { + param([Parameter(Mandatory = $true)][string] $Path) + return Get-Content -Raw -LiteralPath $Path | ConvertFrom-Json +} + +function Get-ObjectPropertyCount { + param([object] $Value) + if ($null -eq $Value -or $null -eq $Value.PSObject) { + return 0 + } + return @($Value.PSObject.Properties).Count +} + +function Get-FirstPropertyValue { + param([object] $Value) + $properties = @($Value.PSObject.Properties) + if ($properties.Count -lt 1) { + return $null + } + return $properties[0].Value +} + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot -Purpose "restart-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) +New-Item -ItemType Directory -Force -Path $outFullDir | Out-Null + +if (-not (Test-Path -LiteralPath $stateFullPath)) { + throw "State file is missing: $stateFullPath" +} + +$beforeState = Read-JsonFile -Path $stateFullPath +$beforeCredit = Get-FirstPropertyValue -Value $beforeState.bridgeCredits +if ($null -eq $beforeCredit) { + throw "No bridge credit exists before restart verification." +} +$beforeBalance = $beforeState.localTestUnitBalances.PSObject.Properties[$beforeCredit.recipientAccountId].Value +if ($null -eq $beforeBalance) { + throw "Credited account balance is missing before restart verification." +} + +& cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $stateFullPath --node-dir $nodeFullDir node-stop | Out-Null +if ($LASTEXITCODE -ne 0) { + throw "node-stop failed." +} +Start-Sleep -Seconds 2 + +& powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "flowchain-node-start.ps1") -StatePath $stateFullPath -NodeDir $nodeFullDir -BlockMs $BlockMs -OutDir $outFullDir | Out-Null +if ($LASTEXITCODE -ne 0) { + throw "node restart start failed." +} + +$status = Read-JsonFile -Path (Join-Path $nodeFullDir "status.json") +if ($status.status -ne "running") { + throw "Node did not report running after restart." +} + +$snapshotPath = Join-Path $outFullDir "restart-state-snapshot.json" +$importedStatePath = Join-Path $outFullDir "restart-imported-state.json" +& cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $stateFullPath export-state --out $snapshotPath | Out-Null +if ($LASTEXITCODE -ne 0) { + throw "export-state failed during restart verification." +} +& cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $importedStatePath import-state --from $snapshotPath | Out-Null +if ($LASTEXITCODE -ne 0) { + throw "import-state failed during restart verification." +} + +$afterState = Read-JsonFile -Path $stateFullPath +$importedState = Read-JsonFile -Path $importedStatePath +$afterCredit = $afterState.bridgeCredits.PSObject.Properties[$beforeCredit.creditId].Value +$afterReceipt = $afterState.bridgeCreditReceipts.PSObject.Properties[$beforeCredit.creditId].Value +$afterReplayCount = Get-ObjectPropertyCount -Value $afterState.bridgeReplayKeys +$afterBalance = $afterState.localTestUnitBalances.PSObject.Properties[$beforeCredit.recipientAccountId].Value + +if ($null -eq $afterCredit -or $null -eq $afterReceipt -or $afterReplayCount -lt 1 -or $null -eq $afterBalance) { + throw "Bridge credit, receipt, replay index, or balance was not preserved after restart." +} +if ($importedState.bridgeCredits.PSObject.Properties[$beforeCredit.creditId].Value.creditId -ne $beforeCredit.creditId) { + throw "Imported state did not preserve bridge credit." +} +if ($importedState.bridgeCreditReceipts.PSObject.Properties[$beforeCredit.creditId].Value.receiptId -ne $beforeCredit.creditId) { + throw "Imported state did not preserve bridge credit receipt." +} +if ($importedState.localTestUnitBalances.PSObject.Properties[$beforeCredit.recipientAccountId].Value.units -ne $afterBalance.units) { + throw "Imported state did not preserve credited balance." +} + +$report = [ordered]@{ + schema = "flowchain.live_l1.restart_verify_report.v0" + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + statePath = $stateFullPath + nodeDir = $nodeFullDir + status = $status + creditId = $beforeCredit.creditId + creditedAccount = $beforeCredit.recipientAccountId + bridgeCredits = Get-ObjectPropertyCount -Value $afterState.bridgeCredits + bridgeCreditReceipts = Get-ObjectPropertyCount -Value $afterState.bridgeCreditReceipts + bridgeReplayKeys = $afterReplayCount + creditedBalance = $afterBalance.units + exportImportPreserved = $true + snapshotPath = $snapshotPath + importedStatePath = $importedStatePath + passed = $true +} + +Write-FlowChainJson -Path (Join-Path $outFullDir "restart-verify-report.json") -Value $report -Depth 20 +$report | ConvertTo-Json -Depth 20 diff --git a/infra/scripts/flowchain-wallet-transfer-e2e.ps1 b/infra/scripts/flowchain-wallet-transfer-e2e.ps1 new file mode 100644 index 00000000..74ae2420 --- /dev/null +++ b/infra/scripts/flowchain-wallet-transfer-e2e.ps1 @@ -0,0 +1,137 @@ +param( + [string] $StatePath = "devnet/local/state.json", + [string] $NodeDir = "devnet/local/node", + [string] $OutDir = "devnet/local/live-l1-bridge-intake", + [string] $ToAccount = "local-account:bridge:live-transfer-receiver", + [UInt64] $AmountUnits = 1, + [int] $TimeoutSeconds = 60 +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. "$PSScriptRoot\flowchain-common.ps1" + +function Read-JsonFile { + param([Parameter(Mandatory = $true)][string] $Path) + return Get-Content -Raw -LiteralPath $Path | ConvertFrom-Json +} + +function Get-FirstPropertyValue { + param([object] $Value) + $properties = @($Value.PSObject.Properties) + if ($properties.Count -lt 1) { + return $null + } + return $properties[0].Value +} + +$repoRoot = Set-FlowChainRepoRoot +Set-FlowChainCargoTargetDir -RepoRoot $repoRoot -Purpose "wallet-transfer-e2e" | 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) +New-Item -ItemType Directory -Force -Path $outFullDir | Out-Null + +$statusPath = Join-Path $nodeFullDir "status.json" +if (-not (Test-Path -LiteralPath $statusPath)) { + throw "Node status is missing. Start the node before running transfer e2e." +} +$status = Read-JsonFile -Path $statusPath +if ($status.status -ne "running") { + throw "Node is not running; status is '$($status.status)'." +} +if (-not (Test-Path -LiteralPath $stateFullPath)) { + throw "State file is missing: $stateFullPath" +} + +$state = Read-JsonFile -Path $stateFullPath +$credit = Get-FirstPropertyValue -Value $state.bridgeCredits +if ($null -eq $credit) { + throw "No bridge credit exists in runtime state." +} +$fromAccount = [string] $credit.recipientAccountId +$fromBalance = $state.localTestUnitBalances.PSObject.Properties[$fromAccount].Value +if ($null -eq $fromBalance -or [UInt64] $fromBalance.units -lt $AmountUnits) { + throw "Credited account $fromAccount does not have enough spendable units." +} +$beforeReceiver = $state.localTestUnitBalances.PSObject.Properties[$ToAccount].Value +$beforeReceiverUnits = if ($null -eq $beforeReceiver) { [UInt64] 0 } else { [UInt64] $beforeReceiver.units } + +$transferId = "transfer:bridge:live:" + ([Guid]::NewGuid().ToString("N")) +$txs = New-Object System.Collections.Generic.List[object] +if ($null -eq $beforeReceiver) { + $txs.Add([ordered]@{ + type = "CreateLocalTestUnitBalance" + accountId = $ToAccount + owner = "operator:bridge:live-transfer" + }) | Out-Null +} +$txs.Add([ordered]@{ + type = "TransferLocalTestUnits" + transferId = $transferId + fromAccountId = $fromAccount + toAccountId = $ToAccount + amountUnits = $AmountUnits + memo = "live-bridge-credit-spend-proof" +}) | Out-Null + +$txPath = Join-Path $outFullDir "wallet-transfer-tx.json" +Write-FlowChainJson -Path $txPath -Value ([ordered]@{ + schema = "flowmemory.local_devnet.wallet_transfer_e2e.v0" + txs = @($txs) +}) + +$submitOutput = (& cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state $stateFullPath --node-dir $nodeFullDir submit-tx --tx-file $txPath --authorized-by operator:bridge:live-transfer 2>&1) -join [Environment]::NewLine +if ($LASTEXITCODE -ne 0) { + throw "Transfer submit failed.`n$submitOutput" +} +$jsonStart = $submitOutput.IndexOf("{") +$jsonEnd = $submitOutput.LastIndexOf("}") +$submit = $submitOutput.Substring($jsonStart, $jsonEnd - $jsonStart + 1) | ConvertFrom-Json +if (@($submit.queued).Count -lt 1) { + throw "Transfer submit did not queue transactions." +} + +$deadline = (Get-Date).AddSeconds($TimeoutSeconds) +$afterState = $null +while ((Get-Date) -lt $deadline) { + try { + $candidate = Read-JsonFile -Path $stateFullPath + $transferRecord = $candidate.balanceTransfers.PSObject.Properties[$transferId].Value + $receiver = $candidate.localTestUnitBalances.PSObject.Properties[$ToAccount].Value + if ($null -ne $transferRecord -and $null -ne $receiver -and [UInt64] $receiver.units -ge ($beforeReceiverUnits + $AmountUnits)) { + $afterState = $candidate + break + } + } + catch { + } + Start-Sleep -Milliseconds 500 +} + +if ($null -eq $afterState) { + throw "Timed out waiting for credited-account transfer to be included." +} + +$receiverAfter = $afterState.localTestUnitBalances.PSObject.Properties[$ToAccount].Value +$senderAfter = $afterState.localTestUnitBalances.PSObject.Properties[$fromAccount].Value +$report = [ordered]@{ + schema = "flowchain.live_l1.wallet_transfer_e2e_report.v0" + generatedAt = (Get-Date).ToUniversalTime().ToString("o") + statePath = $stateFullPath + nodeDir = $nodeFullDir + creditId = $credit.creditId + fromAccount = $fromAccount + toAccount = $ToAccount + amountUnits = $AmountUnits + transferId = $transferId + senderBalanceAfter = $senderAfter.units + receiverBalanceBefore = $beforeReceiverUnits + receiverBalanceAfter = $receiverAfter.units + queued = $submit + passed = $true +} + +Write-FlowChainJson -Path (Join-Path $outFullDir "wallet-transfer-report.json") -Value $report -Depth 20 +$report | ConvertTo-Json -Depth 20 diff --git a/package.json b/package.json index d79d409c..56400a5d 100644 --- a/package.json +++ b/package.json @@ -36,11 +36,18 @@ "flowchain:init": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-init.ps1", "flowchain:start": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-start.ps1", "flowchain:stop": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-stop.ps1", + "flowchain:node:start": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-node-start.ps1", "flowchain:node": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-node.ps1", "flowchain:node:stop": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-node-stop.ps1", "flowchain:node:status": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-node-status.ps1", + "flowchain:node:restart": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-node-restart.ps1", + "flowchain:bridge:ingest": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-bridge-ingest.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:wallet:transfer:e2e": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-wallet-transfer-e2e.ps1", + "flowchain:restart:verify": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-restart-verify.ps1", + "flowchain:live-bridge:status": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-live-bridge-status.ps1", + "flowchain:no-secret:scan": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-no-secret-scan.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:hardware:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File hardware/simulator/flowrouter-smoke.ps1",