Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ generated_accounts.txt
# Foundry/Soldeer dependencies
lib/

/~
43 changes: 35 additions & 8 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ help:
@echo " testnet_debug - Start a local testnet in debug mode"
@echo " testnet_restart - Restart the testnet"
@echo " testnet_load_test - Start testnet with transaction load testing and auto-exit"
@echo " testnet_follower_test - Start testnet and add follower nodes at blocks 5 and 15, exit at block 30"
@echo " testnet-follower - Run a follower node against a running testnet (uses enodes from nodes.csv)"
@echo " genesis - Generate a genesis file"
@echo " node-gen - Generate node enodes and secret keys in CSV format"
@echo " node-help - Show help for the rbft-node binary"
Expand Down Expand Up @@ -67,6 +69,9 @@ help:
@echo " RBFT_EXIT_AFTER_BLOCK=<block> - Exit testnet after reaching this block height"
@echo " RBFT_ADD_AT_BLOCKS=<blocks> - Comma-separated list of block heights to add"
@echo " validators at (e.g., '10,20,30')"
@echo " RBFT_ADD_FOLLOWER_AT=<blocks> - Comma-separated list of block heights to add"
@echo " follower (non-validator) nodes at (e.g., '5,15')"
@echo " Uses key slots from nodes.csv starting at index num_nodes"
@echo ""
@echo " Examples:"
@echo " RBFT_NUM_NODES=7 make genesis"
Expand Down Expand Up @@ -151,13 +156,23 @@ testnet_debug:
RUST_LOG=debug $(MAKE) CARGO_PROFILE=debug testnet_start


# Note this is controllable by environment variables.
# RBFT_NUM_NODES=10 make testnet_load_test
# RBFT_BLOCK_INTERVAL=0.1 make testnet_load_test
# RBFT_BASE_FEE=1000000000000 make testnet_load_test
# see target/release/rbft-utils genesis --help for details.
# Avoid setting log-level here as it is possible to set it before running make.
# RUST_LOG=debug make testnet_load_test
# Test follower node support: starts a 4-validator testnet, adds 2 non-validator follower
# nodes at blocks 5 and 15 via RBFT_ADD_FOLLOWER_AT, then exits at block 30.
# node-gen is given RBFT_NUM_NODES + 2 slots so nodes.csv has keys for the followers.
testnet_follower_test:
mkdir -p $(ASSETS_DIR)
$(CARGO) build --release --bin rbft-node
$(CARGO) build --release --bin rbft-utils
target/release/rbft-utils node-gen --assets-dir $(ASSETS_DIR) \
--num-nodes $$(( $${RBFT_NUM_NODES:-4} + 2 ))
target/release/rbft-utils genesis --assets-dir $(ASSETS_DIR) \
--initial-nodes $${RBFT_NUM_NODES:-4}
RBFT_ADD_FOLLOWER_AT=5,15 RBFT_EXIT_AFTER_BLOCK=30 \
target/release/rbft-utils testnet --init \
--assets-dir $(ASSETS_DIR)
target/release/rbft-utils logjam -q

# Test transaction load handling: starts a 4-validator testnet with megatx enabled, which submits large transactions every block.
testnet_load_test:
mkdir -p $(ASSETS_DIR)
$(CARGO) build --release --bin rbft-node
Expand Down Expand Up @@ -186,6 +201,18 @@ testnet_load_test:
--extra-args "--rpc-cache.max-headers 100"
target/release/rbft-utils logjam -q

# Run a minimal test follower node against an already-running testnet.
# Assumes the testnet was started with node-gen and genesis commands so nodes.csv and genesis.json exist in ASSETS_DIR.
# You may need to remove the reth database directory for the follower node to start successfully.
#
# --trusted-peers is set to all the enodes from nodes.csv for simplicity, but in a real scenario you would typically only trust a subset of validators.
# --chain is set to the same genesis file as the main testnet to ensure it can sync properly, but in a real scenario you would typically use a more minimal genesis for followers.
testnet_follower:
$(CARGO) build --release --bin rbft-node
target/release/rbft-node node --port 12345 \
--chain $(ASSETS_DIR)/genesis.json \
--trusted-peers "$$(awk -F',' 'NR>1{printf "%s%s",sep,$$5; sep=","}' $(ASSETS_DIR)/nodes.csv)"

genesis:
mkdir -p $(ASSETS_DIR)
$(CARGO) run --release --bin rbft-utils -- genesis --assets-dir $(ASSETS_DIR)
Expand Down Expand Up @@ -317,4 +344,4 @@ docker-tag-registry:
.PHONY: help docker-validate docker-build docker-build-dev docker-build-debug docker-run \
docker-run-dev docker-test docker-clean test fmt clippy clean docker-tag-registry \
dafny-translate validator_status status testnet_start testnet_restart genesis node-help \
megatx validator-inspector testnet_load_test testnet_debug
megatx validator-inspector testnet_load_test testnet_follower_test testnet_debug testnet-follower
113 changes: 113 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,119 @@ This will:

The nodes will log to `~/.rbft/testnet/logs/` and store data in `~/.rbft/testnet/db/`.

### Running a Follower Node

A follower node connects to an existing validator network, receives blocks, and keeps a
full copy of the chain. It does not participate in consensus (no `--validator-key`), so it
can be added to or removed from the network at any time without affecting liveness.

Before running a follower you need:
- A running RBFT testnet (e.g. started with `make testnet_start`)
- The `genesis.json` and `nodes.csv` from the testnet assets directory
(default: `~/.rbft/testnet/assets/`)

Extract the validator enode URLs from `nodes.csv` (column 5):

```bash
ENODES=$(awk -F',' 'NR>1{printf "%s%s",sep,$5; sep=","}' ~/.rbft/testnet/assets/nodes.csv)
```

Then start the follower:

```bash
target/release/rbft-node node \
--chain ~/.rbft/testnet/assets/genesis.json \
--datadir /tmp/rbft-follower \
--port 12345 \
--authrpc.port 8651 \
--http --http.port 8600 \
--disable-discovery \
--trusted-peers "$ENODES"
```

Key flags:
- `--chain` — path to the shared `genesis.json` (must match the running network)
- `--datadir` — a fresh directory for the follower's database; must not be shared with
a validator
- `--port` — P2P listen port; pick one that does not conflict with the validator nodes
(default validator ports start at 30303)
- `--authrpc.port` — engine API port; also must not conflict (validator default: 8551+)
- `--disable-discovery` — prevents unwanted discv4/discv5 discovery; peers are supplied
explicitly via `--trusted-peers`
- `--trusted-peers` — comma-separated enode URLs of the validators to connect to
- `--validator-key` is intentionally **omitted** — its absence is what makes this a
follower

### Adding a Validator Node

To add a new validator to a running testnet you need a validator key (for signing QBFT
messages), a P2P secret key (for RLPx networking), and the enode URL derived from it.

**1. Generate keys**

```bash
target/release/rbft-utils validator keygen --ip <YOUR_IP> --port 30305
```

This prints JSON with all four values:

```json
{
"validator_address": "0xABCD...",
"validator_private_key": "0x1234...",
"p2p_secret_key": "abcd...",
"enode": "enode://<pubkey>@<YOUR_IP>:30305"
}
```

Save the keys and capture the enode:

```bash
echo "0x1234..." > validator-key-new.txt
echo "abcd..." > p2p-secret-key-new.txt
ENODE="enode://<pubkey>@<YOUR_IP>:30305"
```

**2. Start the node**

```bash
target/release/rbft-node node \
--chain ~/.rbft/testnet/assets/genesis.json \
--datadir /tmp/rbft-new-validator \
--port 30305 \
--authrpc.port 8652 \
--http --http.port 8601 \
--disable-discovery \
--p2p-secret-key p2p-secret-key-new.txt \
--validator-key validator-key-new.txt \
--trusted-peers "$ENODES" # enodes of existing validators
```

**3. Register the validator in the contract**

The admin key is taken from the `RBFT_ADMIN_KEY` environment variable, which must match
the key used when the genesis was generated. For a fresh local testnet started without
setting `RBFT_ADMIN_KEY`, the default is `0x000...0001`:

```bash
target/release/rbft-utils validator add \
--validator-address 0xABCD... \
--enode "$ENODE" \
--rpc-url http://localhost:8545
```

If `RBFT_ADMIN_KEY` is not set, pass it explicitly:

```bash
target/release/rbft-utils validator add \
--admin-key <ADMIN_PRIVATE_KEY> \
--validator-address 0xABCD... \
--enode "$ENODE" \
--rpc-url http://localhost:8545
```

The new validator becomes active at the next epoch boundary.

### Installing Cast (Foundry)

Cast is a useful tool for interacting with the blockchain:
Expand Down
33 changes: 20 additions & 13 deletions crates/rbft-node/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,27 +25,28 @@ use metrics::QbftMetrics;
// === Node args ===
/// RBFT node configuration
///
/// Environment Variables:
/// RBFT_BLOCK_INTERVAL Override block interval (seconds, float)
/// RBFT_GAS_LIMIT Override gas limit (unsigned integer)
/// RBFT_DEBUG_CATCHUP_BLOCK Only enable chain catchup after this
/// block height (unsigned integer)
/// RBFT_RESEND_AFTER Resend cached messages after this many
/// seconds without block commits (default: 0=disabled)
/// RBFT_FULL_LOGS Emit state logs on every advance (default: false)
/// RBFT_TRUSTED_PEERS_REFRESH_SECS Peer refresh interval (default: 10)
/// Environment Variables (all correspond to a `--flag` CLI arg of the same name):
/// RBFT_VALIDATOR_KEY Path to the validator private key file
/// RBFT_LOGS_DIR Directory for log files
/// RBFT_DB_DIR Directory for database files
/// RBFT_TRUSTED_PEERS_REFRESH_SECS Peer refresh interval in seconds (default: 10)
/// RBFT_FULL_LOGS Emit state logs on every advance (default: false)
/// RBFT_RESEND_AFTER Resend cached messages after N seconds without commits
/// RBFT_DISABLE_EXPRESS Disable express transaction delivery
/// RBFT_DEBUG_CATCHUP_BLOCK Only enable QBFT advance after this block height
/// (node 0 only; useful for debugging catch-up)
#[derive(Debug, Parser, Clone)]
pub struct RbftNodeArgs {
/// Path to the validator private key file
#[arg(long)]
/// Path to the validator private key file. If not provided, the node will be a follower node.
#[arg(long, env = "RBFT_VALIDATOR_KEY")]
pub validator_key: Option<String>,

/// Directory for log files. Defaults to ~/.rbft/testnet/logs
#[arg(long)]
#[arg(long, env = "RBFT_LOGS_DIR")]
pub logs_dir: Option<String>,

/// Directory for database files. Defaults to ~/.rbft/testnet/db
#[arg(long)]
#[arg(long, env = "RBFT_DB_DIR")]
pub db_dir: Option<String>,

/// How often (in seconds) to refresh trusted peer DNS entries and reconnect on changes.
Expand Down Expand Up @@ -75,6 +76,12 @@ pub struct RbftNodeArgs {
/// transactions to the next block proposer).
#[arg(long, env = "RBFT_DISABLE_EXPRESS")]
pub disable_express: bool,

/// Only enable QBFT advance on node 0 after the chain reaches this block height
/// (or another node has already seen a NewBlock at this height). Useful for
/// debugging catch-up behaviour without advancing immediately.
#[arg(long, value_name = "BLOCK", env = "RBFT_DEBUG_CATCHUP_BLOCK")]
pub debug_catchup_block: Option<u64>,
}

fn spawn_trusted_peer_refresh(
Expand Down
32 changes: 18 additions & 14 deletions crates/rbft-node/src/rbft_consensus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ pub use rbft_utils::types::RbftConfig;

use std::{
collections::{HashMap, VecDeque},
env,
time::{Duration, Instant, UNIX_EPOCH},
};

Expand Down Expand Up @@ -179,7 +178,7 @@ mod reload_tests {
nodes: vec![qbft_beneficiary],
..Default::default()
};
let private_key = B256::from([1u8; 32]);
let private_key = Some(B256::from([1u8; 32]));
let node_state =
NodeState::new(blockchain, configuration, qbft_beneficiary, private_key, 0);

Expand Down Expand Up @@ -1512,16 +1511,13 @@ where
}
}

let enabled = match env::var("RBFT_DEBUG_CATCHUP_BLOCK") {
Ok(value) => {
let threshold = value.parse::<u64>().map_err(|e| {
eyre!("RBFT_DEBUG_CATCHUP_BLOCK must be a valid unsigned integer: {e}")
})?;
let enabled = match self.args.debug_catchup_block {
Some(threshold) => {
whoami != 0
|| self.node_state.blockchain().height() >= threshold
|| highest_newblock_message >= threshold
}
Err(_) => true,
None => true,
};

if !enabled {
Expand Down Expand Up @@ -2617,11 +2613,19 @@ async fn attempt_peer_reconnection(

fn get_private_key_and_id(
args: &RbftNodeArgs,
) -> eyre::Result<(alloy_primitives::B256, alloy_primitives::Address)> {
let key = args
.validator_key
.as_ref()
.ok_or_else(|| eyre!("Validator key is required for QBFT nodes"))?;
) -> eyre::Result<(Option<alloy_primitives::B256>, alloy_primitives::Address)> {
// If no validator key file is provided, the node runs as a follower.
// Its address will not appear in the on-chain validator set, so the QBFT
// state machine will treat it as a follower (non-validator): it will only run
// `upon_new_block` and never propose, prepare, commit, or trigger round changes.
let Some(key) = args.validator_key.as_ref() else {
info!(
target: "rbft",
"No --validator-key provided; running as follower node"
);
return Ok((None, Address::ZERO));
};

let key_data = std::fs::read_to_string(key)
.map_err(|e| eyre!("Failed to read validator key file {key:?}: {e}"))?;
let key_data = key_data.trim();
Expand All @@ -2644,7 +2648,7 @@ fn get_private_key_and_id(
let signer = PrivateKeySigner::from_bytes(&private_key)
.map_err(|e| eyre!("Failed to create signer from private key: {e}"))?;
let id = signer.address();
Ok((private_key, id))
Ok((Some(private_key), id))
}

fn now() -> u64 {
Expand Down
7 changes: 5 additions & 2 deletions crates/rbft-utils/src/constants.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
// SPDX-License-Identifier: Apache-2.0
//! Shared constants for RBFT tooling.

/// Well-known admin private key used for local testnets and load generation.
/// Fallback admin private key used when `RBFT_ADMIN_KEY` is not set.
///
/// This key is baked into genesis assets so tooling can assume a funded account.
/// Used by `genesis`, `validator add`, and other commands that require an admin key.
/// Override with the `RBFT_ADMIN_KEY` environment variable to use a different key.
/// **This key is only the actual genesis admin when the testnet was generated without
/// `RBFT_ADMIN_KEY` set.**
pub const DEFAULT_ADMIN_KEY: &str =
"0x0000000000000000000000000000000000000000000000000000000000000001";

Expand Down
Loading
Loading