Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
948 changes: 874 additions & 74 deletions crates/flowmemory-devnet/src/cli.rs

Large diffs are not rendered by default.

19 changes: 18 additions & 1 deletion crates/flowmemory-devnet/src/model.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::hash::{hash_json, keccak_hex};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use thiserror::Error;

pub const STATE_SCHEMA: &str = "flowmemory.local_devnet.state.v0";
Expand Down Expand Up @@ -1209,11 +1209,28 @@ pub fn build_block(state: &mut ChainState) -> Block {
let txs = std::mem::take(&mut state.pending_txs);
let mut receipts = Vec::with_capacity(txs.len());
let mut tx_ids = Vec::with_capacity(txs.len());
let mut included_tx_ids = state
.blocks
.iter()
.flat_map(|block| {
block
.receipts
.iter()
.filter(|receipt| receipt.status == "applied")
.map(|receipt| receipt.tx_id.clone())
})
.collect::<BTreeSet<_>>();

for envelope in txs {
if included_tx_ids.contains(&envelope.tx_id) {
continue;
}
tx_ids.push(envelope.tx_id.clone());
let authorization = envelope.authorization.clone();
let result = apply_transaction(state, &envelope.tx);
if result.is_ok() {
included_tx_ids.insert(envelope.tx_id.clone());
}
receipts.push(BlockReceipt {
tx_id: envelope.tx_id,
status: if result.is_ok() {
Expand Down
201 changes: 200 additions & 1 deletion crates/flowmemory-devnet/tests/devnet_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use flowmemory_devnet::model::{
DevnetError, FLOWPULSE_TOPIC0, LOCAL_TEST_UNIT_ASSET_ID, Transaction, ZERO_HASH,
DevnetError, FLOWPULSE_TOPIC0, GENESIS_HASH, 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,
Expand All @@ -25,6 +25,24 @@ fn state_root_is_deterministic_for_same_inputs() {
assert_eq!(first_block.block_hash, second_block.block_hash);
}

#[test]
fn duplicate_pending_transaction_is_included_once() {
let mut state = genesis_state();
let tx = Transaction::CreateLocalTestUnitBalance {
account_id: "local-account:duplicate".to_string(),
owner: "operator:duplicate".to_string(),
};

let tx_id = queue_transaction(&mut state, tx.clone());
let duplicate_tx_id = queue_transaction(&mut state, tx);
let block = build_block(&mut state);

assert_eq!(tx_id, duplicate_tx_id);
assert_eq!(block.tx_ids, vec![tx_id]);
assert_eq!(block.receipts.len(), 1);
assert_eq!(state.local_test_unit_balances.len(), 1);
}

#[test]
fn deterministic_replay_covers_new_maps_and_anchor() {
let first = run_demo_chain();
Expand Down Expand Up @@ -1270,12 +1288,193 @@ fn cli_node_runs_ten_blocks_and_includes_authorized_inbox_tx() {
state_json["blocks"][0]["receipts"][0]["authorization"]["signer"],
"local-test-operator"
);
assert_eq!(state_json["blocks"][0]["receipts"][0]["status"], "applied");
assert_eq!(state_json["blocks"][0]["receipts"][1]["status"], "applied");
assert!(node_dir.join("node-identity.json").exists());
assert!(node_dir.join("status.json").exists());

std::fs::remove_dir_all(&temp).expect("cleanup temp dir");
}

#[test]
fn cli_sync_reports_incompatible_and_invalid_static_peers() {
let temp = temp_dir("cli-peer-negative-status");
let local_state = temp.join("local-state.json");
let local_node = temp.join("local-node");
let stale_state = temp.join("stale-state.json");
let wrong_chain_state = temp.join("wrong-chain-state.json");
let wrong_genesis_state = temp.join("wrong-genesis-state.json");
let unsupported_state = temp.join("unsupported-state.json");
let invalid_parent_state = temp.join("invalid-parent-state.json");
let peer_config = temp.join("local-peers.json");

for state in [
&local_state,
&stale_state,
&wrong_chain_state,
&wrong_genesis_state,
&unsupported_state,
] {
let init = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet"))
.args(["--state", state.to_str().expect("state path"), "init"])
.status()
.expect("init state");
assert!(init.success());
}

let start = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet"))
.args([
"--state",
local_state.to_str().expect("local state"),
"start",
"--blocks",
"2",
])
.status()
.expect("advance local state");
assert!(start.success());

let mut wrong_chain: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&wrong_chain_state).expect("wrong chain"))
.expect("wrong chain json");
wrong_chain["chainId"] = serde_json::Value::String("flowmemory-wrong-chain-v0".to_string());
wrong_chain["config"]["chainId"] =
serde_json::Value::String("flowmemory-wrong-chain-v0".to_string());
std::fs::write(
&wrong_chain_state,
serde_json::to_string_pretty(&wrong_chain).expect("wrong chain json body"),
)
.expect("write wrong chain");

let wrong_genesis_hash = format!("0x{}", "1".repeat(64));
let mut wrong_genesis: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(&wrong_genesis_state).expect("wrong genesis"),
)
.expect("wrong genesis json");
wrong_genesis["genesisHash"] = serde_json::Value::String(wrong_genesis_hash.clone());
wrong_genesis["config"]["genesisHash"] = serde_json::Value::String(wrong_genesis_hash.clone());
wrong_genesis["parentHash"] = serde_json::Value::String(wrong_genesis_hash.clone());
std::fs::write(
&wrong_genesis_state,
serde_json::to_string_pretty(&wrong_genesis).expect("wrong genesis json body"),
)
.expect("write wrong genesis");

let mut invalid_parent: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&local_state).expect("local state"))
.expect("local json");
invalid_parent["blocks"][0]["parentHash"] =
serde_json::Value::String(format!("0x{}", "2".repeat(64)));
std::fs::write(
&invalid_parent_state,
serde_json::to_string_pretty(&invalid_parent).expect("invalid parent json body"),
)
.expect("write invalid parent");

let config = serde_json::json!({
"schema": "flowmemory.local_devnet.peer_config.v1",
"nodeId": "node:test:local",
"networkProfile": "local-file-private-testnet",
"chainId": "flowmemory-local-devnet-v0",
"genesisHash": GENESIS_HASH,
"protocolVersion": "flowchain-local-network/0.1.0",
"role": "block-producer",
"listenAddress": "flowchain-local://node-test-local",
"bindAddress": "local-file://node-test-local",
"statePath": local_state.to_string_lossy().to_string(),
"dataDir": local_node.to_string_lossy().to_string(),
"staticPeers": [
{
"nodeId": "node:test:stale",
"role": "full-node",
"statePath": stale_state.to_string_lossy().to_string(),
"chainId": "flowmemory-local-devnet-v0",
"genesisHash": GENESIS_HASH,
"protocolVersion": "flowchain-local-network/0.1.0"
},
{
"nodeId": "node:test:wrong-chain",
"role": "full-node",
"statePath": wrong_chain_state.to_string_lossy().to_string(),
"chainId": "flowmemory-wrong-chain-v0",
"genesisHash": GENESIS_HASH,
"protocolVersion": "flowchain-local-network/0.1.0"
},
{
"nodeId": "node:test:wrong-genesis",
"role": "full-node",
"statePath": wrong_genesis_state.to_string_lossy().to_string(),
"chainId": "flowmemory-local-devnet-v0",
"genesisHash": wrong_genesis_hash,
"protocolVersion": "flowchain-local-network/0.1.0"
},
{
"nodeId": "node:test:unsupported",
"role": "full-node",
"statePath": unsupported_state.to_string_lossy().to_string(),
"chainId": "flowmemory-local-devnet-v0",
"genesisHash": GENESIS_HASH,
"protocolVersion": "flowchain-local-network/9.9.9"
},
{
"nodeId": "node:test:invalid-parent",
"role": "full-node",
"statePath": invalid_parent_state.to_string_lossy().to_string(),
"chainId": "flowmemory-local-devnet-v0",
"genesisHash": GENESIS_HASH,
"protocolVersion": "flowchain-local-network/0.1.0"
}
]
});
std::fs::write(
&peer_config,
serde_json::to_string_pretty(&config).expect("peer config"),
)
.expect("write peer config");

let output = Command::new(env!("CARGO_BIN_EXE_flowmemory-devnet"))
.args([
"--state",
local_state.to_str().expect("local state"),
"--node-dir",
local_node.to_str().expect("local node"),
"sync",
"--node-id",
"node:test:local",
"--peer-config",
peer_config.to_str().expect("peer config"),
])
.output()
.expect("run sync");
assert!(output.status.success());

let sync: serde_json::Value = serde_json::from_slice(&output.stdout).expect("sync json");
let peers = sync["status"]["peers"].as_array().expect("peers");
let find_peer = |peer_id: &str| {
peers
.iter()
.find(|peer| peer["peerId"] == peer_id)
.unwrap_or_else(|| panic!("missing peer {peer_id}"))
};

assert_eq!(find_peer("node:test:stale")["syncStatus"], "stalePeer");
assert_eq!(find_peer("node:test:wrong-chain")["status"], "wrongChain");
assert_eq!(
find_peer("node:test:wrong-genesis")["status"],
"wrongGenesis"
);
assert_eq!(
find_peer("node:test:unsupported")["status"],
"unsupportedProtocol"
);
assert_eq!(
find_peer("node:test:invalid-parent")["rejectedBlock"]["reason"],
"invalidParentBlock"
);

std::fs::remove_dir_all(&temp).expect("cleanup temp dir");
}

#[test]
fn cli_static_peer_sync_reconciles_two_local_node_states() {
let temp = temp_dir("cli-peer-sync");
Expand Down
94 changes: 91 additions & 3 deletions docs/LOCAL_DEVNET.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@ 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 runtime is still local/private and
deterministic. It now includes a bounded local node mode plus deterministic
file-relay peer sync for multi-node smoke testing; it is not LAN socket gossip,
public validator networking, or production consensus.

Initialize state:

Expand Down Expand Up @@ -113,7 +115,93 @@ Run the full smoke flow:
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.
`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.

## Private Local Networking

The local/private networking layer uses deterministic local files as the
transport boundary. Each node has an explicit identity, state file, node
directory, listen-address string, bind-address string, and static peer config.
Nodes exchange status by reading peer state and relaying locally authorized
transactions into peer inboxes. Blocks are reconciled by validating the peer
chain summary and adopting the higher valid canonical state.

Run the strict two-node network E2E:

```powershell
npm run flowchain:network:e2e
```

Run the required multi-node smoke alias:

```powershell
npm run flowchain:multi-node:smoke
```

Reports are written under ignored local paths:

```text
devnet/local/network-e2e/network-e2e-report.json
devnet/local/multi-node-smoke/multi-node-smoke-report.json
```

The E2E starts node A and node B, submits a locally authorized transaction to
node A, proves node B can query the resulting local balance/faucet state after
sync, stops node B, advances node A, restarts node B, and proves both nodes end
at the same height and state root. It also records negative peer evidence for
wrong chain ID, wrong genesis hash, unsupported protocol version, stale peer
head, invalid parent block, and duplicate transaction IDs.

Peer config shape:

```json
{
"schema": "flowmemory.local_devnet.peer_config.v1",
"nodeId": "node:network:a",
"networkProfile": "local-file-private-testnet",
"chainId": "flowmemory-local-devnet-v0",
"genesisHash": "0x0f23c892cbd2d00c10839d97ddab833698a83f8df8d6df27ceac03cfdd4b7bc9",
"protocolVersion": "flowchain-local-network/0.1.0",
"role": "block-producer",
"listenAddress": "flowchain-local://node-network-a@devnet/local/network-e2e/node-a",
"bindAddress": "local-file://devnet/local/network-e2e/node-a#node-network-a",
"dataDir": "devnet/local/network-e2e/node-a",
"statePath": "devnet/local/network-e2e/node-a-state.json",
"staticPeers": [
{
"nodeId": "node:network:b",
"role": "full-node",
"peerAddress": "flowchain-local://node-network-b@devnet/local/network-e2e/node-b",
"listenAddress": "flowchain-local://node-network-b@devnet/local/network-e2e/node-b",
"bindAddress": "local-file://devnet/local/network-e2e/node-b#node-network-b",
"nodeDir": "devnet/local/network-e2e/node-b",
"statePath": "devnet/local/network-e2e/node-b-state.json",
"chainId": "flowmemory-local-devnet-v0",
"genesisHash": "0x0f23c892cbd2d00c10839d97ddab833698a83f8df8d6df27ceac03cfdd4b7bc9",
"protocolVersion": "flowchain-local-network/0.1.0"
}
]
}
```

`node-status` exposes dashboard-safe network metadata without reading internal
logs:

- `networkProfile`, `chainId`, `genesisHash`, `protocolVersion`, `role`
- `listenAddress`, `bindAddress`, `statePath`, `nodeDir`
- `blockHeight`, `finalizedHeight`, `latestBlockHash`, `stateRoot`
- `syncStatus`, `staticPeerSync`, `peers[]`, `rejectedBlocks[]`
- per-peer `connectionStatus`, `status`, `syncStatus`, `latestHeight`,
`latestHash`, `lastSeenHeight`, `lastSeenHash`, `remoteStateRoot`,
`reconnectAttempts`, and `rejectedBlock`

Manual node commands:

```powershell
cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state devnet/local/network-e2e/node-a-state.json --node-dir devnet/local/network-e2e/node-a node --node-id node:network:a --peer-config devnet/local/network-e2e/node-a-peers.json --max-blocks 3
cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state devnet/local/network-e2e/node-b-state.json --node-dir devnet/local/network-e2e/node-b sync --node-id node:network:b --peer-config devnet/local/network-e2e/node-b-peers.json
cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state devnet/local/network-e2e/node-b-state.json --node-dir devnet/local/network-e2e/node-b node-status
```

Import a FlowPulse observation fixture:

Expand Down
20 changes: 20 additions & 0 deletions docs/agent-runs/production-l1-networking/CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Production L1 Networking Checklist

- [x] Read `AGENTS.md`.
- [x] Read `docs/START_HERE.md`.
- [x] Read `docs/FLOWMEMORY_HQ_CONTEXT.md`.
- [x] Read `docs/CURRENT_STATE.md`.
- [x] Read `docs/LOCAL_DEVNET.md`.
- [x] Inspect current devnet crate and multi-node scripts.
- [x] Add peer config and node identity support.
- [x] Add handshake compatibility checks and peer status output.
- [x] Add deterministic transaction and block reconciliation.
- [x] Add restart catch-up proof.
- [x] Add required negative-case evidence.
- [x] Write multi-node smoke and network E2E reports.
- [x] Update `docs/LOCAL_DEVNET.md`.
- [x] Write required proof and handoff docs.
- [x] Run Rust tests.
- [x] Run `npm run flowchain:multi-node:smoke`.
- [x] Run `npm run flowchain:network:e2e` if added.
- [x] Run `git diff --check`.
Loading