Node operation
Running qubits-node: every subcommand and flag, what each combination of flags starts, the single-validator and multi-validator startup sequences, how the process is built, the mempool and signature cache, exits and shutdown, health, metrics, logs and resources. This is a reference for node operators and for auditors reading the node. Commands and log lines come from chains run with the 0.2.3 binaries. Where a limit is given, it is a constant in the code.
The binary
qubits-node init --genesis FILE --data-dir DIR
qubits-node run --data-dir DIR [options]
qubits-node era fetch | verify | import
qubits-node --version prints qubits-node 0.2.3, and --help works at every level. There is no configuration file. Apart from the Rust runtime's own RUST_BACKTRACE, the only environment variable that matters is RUST_LOG.
init
Creates a chain database from a genesis file.
| Flag | Type | Default | Effect |
|---|---|---|---|
--genesis | path | required | Genesis JSON. See Genesis |
--data-dir | path | required | Database directory, created if missing |
$ qubits-node init --genesis genesis.json --data-dir data
initialized chain `qubits-example` in data
genesis hash: 01481e2aa25b64045d1ef080cac50cffd33e2eb0cfa05e0e765bfd8587305517
A directory that holds a chain is refused: configuration: data already holds a chain. An invalid genesis is refused before the directory is touched.
run
Runs the node and its HTTP API. There are fourteen flags, one of them hidden.
| Flag | Type | Default | Effect |
|---|---|---|---|
--data-dir | path | required | The directory from init. A directory without a chain exits with an error telling you to run init first |
--rpc-addr | IP:PORT | 127.0.0.1:8545 | HTTP API address, bound before catching up |
--validator-key | path | none | Encrypted validator key file. Needs --password-file. Without it the node only serves the API |
--password-file | path | none | The first line of this file is the key's password |
--block-interval-ms | u64 | 100 | Shortest time between two blocks, whoever proposes them, measured from the last block seen. Each proposer applies its own value, and received blocks are not checked against it |
--round-timeout-ms | u64 | 1500 | Consensus round timeout. It grows by half per failed round, see below |
--p2p-addr | IP:PORT | 0.0.0.0:9545 | Where to accept validator connections. Not bound by API nodes or by a lone validator |
--peer | KEY=IP:PORT, repeatable | none | Another validator. KEY is a hex public key from qubits key show, or a position in the genesis validators. List every current validator, and any expected to join |
--follow | URL, repeatable | none | HTTP API of another node to sync from, verified and not trusted. See roles |
--prune-keep | u64, at least 100 | none (all state) | Keep state for the newest N blocks. Blocks are kept. See state pruning |
--snapshot-if-behind | u64 | 1000 | A fresh node this many blocks behind takes a snapshot instead of replaying |
--history-keep | 7d, 12h, 30m, 45s or all | 7d | How long to keep blocks. Epoch ends and one block in 8,192 stay. See history expiry |
--ws-limit | Number | 1024 | WebSocket connections open at once on /v1/ws. See WebSocket |
--era-length | u64 | 8192 | Hidden, for tests only. Blocks per era file. Must be a positive multiple of 64, and every node of a chain must agree on it |
Addresses are parsed as socket addresses, so a host name is refused: HOST:PORT must be an IP address and port.
The round timeout is round_timeout_ms * 3^failed / 2^failed in integers, with failed = min(round - high_qc.round - 1, 6), so the default tops out at 17,085 ms. One quarter of the same flag, 375 ms by default, is the grace before a leader sends a certificate and before a missing block is requested. See Consensus.
Timing has two more fields with no flag: tx_ttl_ms, 600,000, and regossip_ms, 3,000. They are fixed in the binary.
Era subcommands
Era files are described on Sync and history.
era fetch downloads eras from a node's HTTP API into a directory.
| Flag or argument | Type | Default | Effect |
|---|---|---|---|
--from | URL | required | The node, like http://127.0.0.1:8545 |
--out | path | required | Directory to write the files into, created if missing |
ERA... | u64, positional | every era offered | Era indexes |
--to | u64 | none | Conflicts with ERA. For a node holding blocks from this height on: the part of that era up to the height, then every complete era below |
era verify checks files against a genesis file, from the genesis validators up. It needs no database, and it exits with 1 if any file is invalid.
| Flag or argument | Type | Default | Effect |
|---|---|---|---|
--genesis | path | required | The genesis JSON |
FILES... | paths, positional | none | Era files |
era import takes era files into a stopped node's database. They are executed after the head, or put back below the oldest block held.
| Flag or argument | Type | Default | Effect |
|---|---|---|---|
--data-dir | path | required | The database |
FILES... | paths, positional | none | Era files, in order: oldest first to append, newest first to put back |
Roles
What run starts depends on the key, on whether the key is in the current validator set, and on --follow.
--validator-key | Key in set | --follow | Other validators | Result |
|---|---|---|---|---|
| no | no | Serves the database as it is. Nothing commits, and submitted transactions stay in the mempool | ||
| no | yes | API node. Follows over HTTP and forwards submitted transactions to every source | ||
| yes | yes | no | none, no --peer | Lone validator. No P2P listener |
| yes | yes | yes | none, no --peer | Catches up, then as above. --follow is not used after that |
| yes | yes | no | others, or any --peer | Validator over P2P |
| yes | yes | yes | others, or any --peer | Catches up, then validator over P2P, and keeps up over HTTP while running |
| yes | no | yes | any | Standby. Connects to P2P, follows over HTTP, and becomes a validator once a change adds the key |
| yes | no | no | any | Exits: {key} is not in the current validator set; add --follow to wait for a validator-set change that adds it |
In every P2P row, a current validator with no --peer entry stops the start: {n} current validators have no --peer address.
Startup order
run does these in order. The HTTP server starts before catching up, so validators starting together can follow each other.
- Set the panic hook and the log subscriber.
Node::open(data_dir).- Apply
--prune-keepand--era-length. - Spawn the
historythread, unless--history-keep all. - Read the key file and the password, and decrypt the key.
- Start the Tokio runtime, bind
--rpc-addr, and start serving. - For a key in the set with
--follow, logcatching up before joining consensusand runcatch_upfor at most 3,600 s. A failure logs a warning, and startup goes on. - For no key with
--follow, callforward_toand spawn thefollowerthread. - For a key not in the set without
--follow, exit. - Start the role from the table above. For P2P, resolve
--peerentries, open the transport, and logaccepting validator connections on {addr}. - Log
serving HTTP API on http://{addr}, then wait for Ctrl-C or for the server to fail.
A single validator
This is the first-deployment shape. Blocks are produced by one key, there are no peers, and no P2P port is opened.
qubits key new --out validator.json
qubits key show --key validator.json # the public key for genesis
qubits key new --out alice.json
qubits address --key alice.json # the address to fund
# write genesis.json, see /docs/state#genesis
qubits-node init --genesis genesis.json --data-dir data
qubits-node run --data-dir data --validator-key validator.json --password-file pw
pw holds the key's password on its first line. A run logs:
2026-11-07T01:31:44.309250Z INFO qubits_node::validator: consensus epoch started epoch=0 validators=1 index=0
2026-11-07T01:31:44.309335Z INFO qubits_node: serving HTTP API on http://127.0.0.1:28545 chain=qubits-docs height=0 validator=true
Height stays 0 until a transaction arrives, because an idle chain makes no blocks.
Several validators
Genesis holds all validator keys, and every validator gets the same genesis file. Each runs init once on its own data directory. For four validators on 10.0.0.1 to 10.0.0.4, the first runs:
qubits-node run \
--data-dir data \
--validator-key validator.json --password-file pw \
--rpc-addr 10.0.0.1:8545 --p2p-addr 10.0.0.1:9545 \
--peer 1=10.0.0.2:9545 --peer 2=10.0.0.3:9545 --peer 3=10.0.0.4:9545 \
--follow http://10.0.0.2:8545 \
--follow http://10.0.0.3:8545 \
--follow http://10.0.0.4:8545
The others run the same with their own addresses. Start them in any order. --follow needs each validator's --rpc-addr reachable from the others, and a validator with it syncs before voting. --peer indexes refer to genesis, so use hex keys for validators added later. The lower key dials the higher one. Both sides need the entry, because the peer list is also the list of keys a node accepts connections from. See Networking.
A four-validator start, seen from index 1:
INFO qubits_node: catching up before joining consensus
INFO qubits_node: accepting validator connections on 127.0.0.1:29601
INFO qubits_node::validator: consensus epoch started epoch=0 validators=4 index=1
INFO qubits_node: serving HTTP API on http://127.0.0.1:28601 chain=qubits-docs-4 height=0 validator=true
INFO qubits_node::p2p: validator connected peer=d7aed88a7406
INFO qubits_node::p2p: validator connected peer=71e0a5ec5bc4
INFO qubits_node::p2p: validator connected peer=6910d647238d
INFO qubits_node::validator: status height=0 round=1 epoch=0 peers=3 mempool=0 in_msgs=0 in_kb=0 out_msgs=0 out_kb=0
The chain needs a quorum of n minus f validators up to make progress, 3 of 4 here.
Validator set changes are on Validators and epochs. A key added later starts as a standby with --follow and a --peer for every current validator, and logs not a validator yet: following the chain until a validator-set change adds this key.
A validator's votes are recorded in
consensus_safetyin its data directory, synced before each vote leaves. A wiped or restored directory forgets them, and the key can then sign a second vote in a round it already voted in. Restart validators on their own directory.
An API node
An API node has no validator key and serves the same HTTP API.
qubits-node init --genesis genesis.json --data-dir api
qubits-node run --data-dir api --rpc-addr 127.0.0.1:8545 \
--follow http://10.0.0.1:8545 --follow http://10.0.0.2:8545
It joins from a snapshot if the source is more than --snapshot-if-behind blocks ahead, and then replays. Transactions submitted to it go through the same checks as on a validator, then are POSTed to every source. An archive node sets --history-keep all, leaves out --prune-keep, and sets --snapshot-if-behind above the chain's height so that a fresh start replays from genesis instead of taking a snapshot. It needs a source with full history. Trust and verification are on Sync and history.
Structure
pub struct Node {
store: Arc<Store>,
genesis: Genesis,
genesis_hash: Hash,
mempool: Arc<Mutex<Mempool>>,
signatures: Arc<SignatureCache>,
gossip: Mutex<Option<Arc<dyn Transport>>>,
forward: Mutex<Option<SyncSender<SignedTransaction>>>,
keep_versions: Arc<AtomicU64>,
era_length: AtomicU64,
submits: Arc<tokio::sync::Semaphore>,
metrics: Metrics,
}
pub struct Validator {
node: Arc<Node>,
key: SecretKey,
timing: Timing,
last_housekeeping: u64,
last_status: u64,
reported: Traffic,
keeping_up: Option<KeepingUp>,
transport: Arc<dyn Transport>,
intake: Intake,
current: Option<EpochEngine>,
retired: Option<(EpochEngine, u64)>,
}
pub struct Timing {
pub round_timeout_ms: u64,
pub min_block_interval_ms: u64,
pub tx_ttl_ms: u64,
pub regossip_ms: u64,
}
Node is the state every role shares: store, mempool, signature cache, and the two ways out for a transaction, gossip and forwarding. Validator owns the consensus engine for the current epoch, and the previous one for 60 s (RETIRED_FOR_MS), which votes for nothing and writes nothing but helps laggards.
The engine is a state machine with no I/O of its own. The validator thread feeds it and carries out what it returns:
- Wait for a message, for at most the time to the engine's next deadline and never more than 50 ms.
- Hand a message to
handle, which decodes it and routes it by epoch to the current or retired engine. On a timeout, calltick. - If the deadline has passed, call
tick. actcarries out eachOutputin order.PersistSafetyandPersistBlockare written with sync before later outputs run, so no vote leaves before the disk has it.SendandBroadcastgo to the transport.- An error panics the thread on purpose.
tick logs status every 10 s, runs keep_up, updates the gauges, and every regossip_ms expires and re-gossips the mempool.
Threads
| Thread | Exists on | Work |
|---|---|---|
| main | all | Runs the runtime and waits for Ctrl-C |
| Tokio workers | all | HTTP server, P2P connections |
| Tokio blocking pool | all | Each submission and view, each era being streamed |
validator-{8 hex of the key} | validators | The engine loop above, including execution and commits |
tx-intake | validators | Admission of gossiped transactions |
keep-up | validators with --follow | Polls the sources' height every 2 s |
standby | standbys | Passes every 100 ms until the key is in the set |
follower | API nodes | Sync passes, mempool expiry and re-forwarding every 3 s |
tx-forward | API nodes | POSTs transactions to every source, 5 s timeout each |
history | all, unless --history-keep all | Drops old blocks |
Mempool
The mempool is in memory, and a restart loses it. It keeps transactions in arrival order, and that is the only order: fees do not affect it.
| Constant | Value | Meaning |
|---|---|---|
MAX_MEMPOOL_TRANSACTIONS | 10,000 | across all senders |
MAX_PER_SENDER | 64 | per sender address |
tx_ttl_ms | 600,000 ms | age at which a transaction is dropped, 10 minutes |
regossip_ms | 3,000 ms | how often validators expire and re-gossip |
| re-gossip batch | 64 | oldest transactions sent per round |
INTAKE_QUEUE | 1,024 | gossiped transactions waiting for admission; more are dropped |
FORWARD_QUEUE | 1,024 | transactions waiting to be forwarded; more are dropped |
REFORWARD_MS | 3,000 ms | API node's re-forward interval |
MAX_CONCURRENT_SUBMITS | 32 | submissions and views running at once |
Admission
Node::accept is the same for HTTP and gossip.
- Take the mempool lock.
admissionrefuses a transaction already in the pool (Duplicate), a full pool, or a sender at 64 (MempoolFull). Copy the sender's waiting transactions, and release the lock. - Look the id up in
tx_index. An included transaction isDuplicate. - Read the head, and build a
BlockEnvfor heighthead + 1atmax(now, head.timestamp + 1). - Create an
Executoron the store with the signature cache,without_contracts(). - Apply the sender's waiting transactions, without checking their signatures again and ignoring failures, so the nonce and balance are what they will be.
applythe new transaction. A failure isInvalid.- Take the lock, run
admissionagain, and insert.
Signatures are checked in step 6 with no lock held. ChainApp::propose, has_work and write_committed take the same lock on the consensus thread, and ML-DSA verification under it would hold up consensus. That is why admission runs twice: the pool can change between the steps.
without_contracts() means a contract call is checked for fee, nonce and signatures, and not run. Submitting must not run code for free.
Node::submit adds the transaction received log line, broadcasts to peers and forwards to sources.
| Result | HTTP | Body |
|---|---|---|
| Accepted | 202 | { "id": "..." } |
Invalid | 400 | the rule that failed, like unauthorized: invalid signature |
Duplicate | 409 | transaction already known |
MempoolFull | 503 | mempool is full, try again later |
A gossiped transaction takes Validator::handle to Intake::push, a try_send onto a queue, and thread tx-intake runs accept. A validator doesn't pass it on when it arrives. It reaches peers through re-gossip if it is still waiting.
Leaving the pool
| Cause | Where |
|---|---|
| Included in a committed block | write_committed |
| Its nonce is below the sender's new nonce | write_committed |
| Failed while a proposer built a block | ChainApp::propose |
received + tx_ttl_ms <= now | expire, every regossip_ms on validators and every 3 s on API nodes |
valid_until_ms < now | same |
A proposer takes the first 2 * max_block_transactions waiting transactions that aren't in uncommitted parents, and builds from those. See speculative execution.
Every regossip_ms a validator broadcasts up to 64 of the oldest transactions that have waited at least that long. An API node re-forwards the same way every 3 s. Forwarded transactions that sit in a mempool also clear from it when the node sees them in a committed block.
An expired transaction logs dropped expired transactions expired=1.
Signature cache
A validator checks a transaction when it arrives, and again in every block built on it. ML-DSA makes that the most expensive step of a block, so SignatureCache remembers what verified. One cache is shared by accept, ChainApp and apply_proven.
| Constant | Value | Meaning |
|---|---|---|
GENERATION | 200,000 | entries per generation; at most two generations are kept |
PARALLEL_FROM | 8 | fewest missing signatures worth using threads for |
An entry is digest("qubits.sh 2026-11 signature cache", raw key ‖ message length as u64 LE ‖ message ‖ raw signature). It is a fact about a key, a message and a signature, so it never goes stale. Whether that key may sign for the account is still decided at execution. Only Domain::Transaction signatures that verified are remembered, and a failure is not.
When young reaches GENERATION, it replaces old, and a new young starts.
Executor::prewarm runs before each block executes:
- For each transaction, find the policy that must sign: the account's
author therevealfor an owner,vault.recoveryfor a recovery signature. - Collect
(key, message, signature)jobs. SignatureCache::prewarmdrops the ones it knows. If fewer than 8 are left, or the machine has one thread, it returns.- Otherwise it splits them across
available_parallelismscoped threads and verifies. Failures are left for execution to report.
This is only a head start. A transaction that changes a key makes a later one miss, and that one verifies in turn.
Exits and supervision
| Exit | Meaning |
|---|---|
| 0 | Clean stop after SIGINT |
| 1 | Startup or command error, printed as Error: ... |
| 2 | Bad command line |
| 101 | A panic anywhere |
| killed by signal | SIGTERM and others: no handler, no cleanup |
A panic in any thread exits the whole process, rather than keep serving the API without consensus. The hook prints the panic, then fatal: stopping the node, and exits with 101, so a supervisor must restart it. Storage failures in consensus panic on purpose. A real one, forced with a file size limit:
thread 'validator-6910d647' (929544) panicked at crates/qubits-node/src/validator.rs:702:25:
validator stopped: storage: IO error: While appending to file: p-v0/000011.log: File too large
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
fatal: stopping the node
Panic output goes to stderr, logs to stdout. Every commit and every vote record is a synced batch, and a restarted validator resumes from ConsensusTip, SafetyRecord and pending_blocks, so a crash at any point leaves a consistent database. See Consensus.
Only one process can open a data directory. A second fails on RocksDB's lock: storage: IO error: While lock file: {dir}/LOCK: Resource temporarily unavailable. A port in use fails with binding {addr}.
Shutdown
tokio::signal::ctrl_c handles SIGINT only. On it, the node logs shutting down, drops the validator handle, which stops and joins its thread, then the follower and the history thread, and exits with 0.
SIGTERM has no handler. The process ends at once, with no shutting down line (measured: killed by signal 15). The database stays consistent, since it has to survive a crash anyway, but a clean stop needs SIGINT. Supervisors usually send SIGTERM, so set the stop signal, as in the systemd example.
Health and metrics
Both sit on --rpc-addr next to /v1.
GET /health answers ok {height} and a newline, or 500 if the head can't be read:
ok 131
It says the process serves and can read its head. It does not say consensus is moving, and neither does a flat height: an idle chain makes no blocks.
GET /metrics is Prometheus text. Each metric has a # TYPE line:
# TYPE qubits_height gauge
qubits_height 103
# TYPE qubits_peers gauge
qubits_peers 3
# TYPE qubits_messages_in_total counter
qubits_messages_in_total 2390
| Metric | Type | Meaning | On API nodes |
|---|---|---|---|
qubits_height | gauge | Head height | yes |
qubits_mempool_size | gauge | Transactions waiting | yes |
qubits_round | gauge | Current consensus round | 0 |
qubits_epoch | gauge | Current epoch | 0 |
qubits_peers | gauge | Validators connected | 0 |
qubits_ws_connections | gauge | Open connections on /v1/ws | yes |
qubits_messages_in_total | counter | P2P messages received | 0 |
qubits_bytes_in_total | counter | P2P bytes received | 0 |
qubits_messages_out_total | counter | P2P messages sent, counting each peer of a broadcast | 0 |
qubits_bytes_out_total | counter | P2P bytes sent | 0 |
qubits_syncs_total | counter | HTTP syncs by keep_up that succeeded | 0 |
qubits_shards_rebuilt_total | counter | Proposals rebuilt from Reed-Solomon shards | 0 |
qubits_snapshot_syncs_total | counter | Snapshots taken | yes |
qubits_history_pruned_total | counter | Blocks dropped by history expiry | yes |
A qubits_peers below n - 1 shows a lost link even on an idle chain.
Logs
Logs go to stdout through tracing, as a UTC timestamp, a level, a target and fields, with ANSI colours only when stdout is a terminal. The filter comes from RUST_LOG, default info. era subcommands log too, so an era import shows committed block lines.
2026-11-07T01:31:44.461937Z INFO qubits_node: transaction received id=8d772ab9e0738a4c60b154196e78ef1ea9abe4b747bedad11e7c7d5c0b6e3b25 sender=qb1mhjvmv2rfrw23yazwauh047rdrfw35dxw7lcxr8a9mkytfcwgghqrqmlgl nonce=0 fee=5432 bytes=5432
2026-11-07T01:31:44.625011Z INFO qubits_node::app: committed block height=1 round=1 txs=40 kb=212 id=fe10b19f2b035705701de8d05afd95ddc3257bc811aa6cbf362695d2e61e7b74
2026-11-07T01:35:14.893031Z INFO qubits_node::validator: status height=53 round=55 epoch=0 peers=3 mempool=40 in_msgs=202 in_kb=3315 out_msgs=204 out_kb=3538
2026-11-07T01:41:02.271363Z INFO qubits_node::sync: following the chain height=357 mempool=0
There is one line per committed block and received transaction, and a summary every 10 s: status on validators, with traffic since the last line, and following the chain on API nodes. A standby logs no summary until it joins.
| Level | Target | Message | Fields |
|---|---|---|---|
| INFO | qubits_node | serving HTTP API on http://{addr} | chain, height, validator (true on a standby too) |
| INFO | qubits_node | catching up before joining consensus | |
| WARN | qubits_node | could not catch up; joining consensus anyway | error |
| INFO | qubits_node | accepting validator connections on {addr} | |
| INFO | qubits_node | not a validator yet: following the chain until a validator-set change adds this key | |
| INFO | qubits_node | shutting down | |
| INFO | qubits_node | transaction received | id, sender, nonce, fee, bytes |
| INFO | qubits_node | dropped expired transactions | expired |
| WARN | qubits_node | could not forward transaction | url, error |
| INFO | qubits_node::app | committed block | height, round, txs, kb, id |
| ERROR | qubits_node::app | could not build a block | error |
| WARN | qubits_node::app | refusing block | id, error |
| WARN | qubits_node::app | refusing block: its roots do not match execution | id |
| INFO | qubits_node::validator | consensus epoch started | epoch, validators, index |
| INFO | qubits_node::validator | status | height, round, epoch, peers, mempool, in_msgs, in_kb, out_msgs, out_kb |
| INFO | qubits_node::validator | joined the validator set | |
| WARN | qubits_node::validator | could not start the validator | error |
| WARN | qubits_node::validator | this key is no longer a validator; consensus stopped | |
| WARN | qubits_node::validator | syncing from the follow sources | behind |
| WARN | qubits_node::validator | sync failed | error |
| INFO | qubits_node::p2p | validator connected, validator disconnected | peer |
| WARN | qubits_node::p2p | could not accept a connection | error |
| INFO | qubits_node::sync | following the chain | height, mempool |
| WARN | qubits_node::sync | sync pass failed | error |
| INFO | qubits_node::sync | taking the state from a snapshot | height |
| INFO | qubits_node::sync | restoring the state | leaves |
| INFO | qubits_node::sync | state restored from a snapshot | height, leaves, chunks |
| INFO | qubits_node::history | dropped old blocks | deleted, from |
| WARN | qubits_node::history | could not drop old blocks | error |
| ERROR | qubits_node::rpc | request failed | error |
| WARN | qubits_node::rpc | could not stream an era | error, index |
RUST_LOG=qubits_node=debug adds every message sent and received, by type, round and size. kind is one of proposal, certify, block, vote, timeout, sync, block-request and transaction. from and to are the first 6 bytes of the raw key.
DEBUG qubits_node::validator: received from=ead4f8458275 kind="proposal" round=1 epoch=0 bytes=220768
DEBUG qubits_node::validator: sent to="all" kind="vote" round=1 epoch=0 bytes=3367 peers=3
DEBUG qubits_node::validator: received from=d7aed88a7406 kind="vote" round=1 epoch=0 bytes=3367
DEBUG qubits_node::p2p: refused connection from=127.0.0.1:62742 error=not a configured validator
Plain RUST_LOG=debug turns on every dependency too.
Exposing the API
The server is axum::serve with no limit on HTTP connections and no header timeout, and it speaks plain HTTP and ws://. Run public API nodes behind a reverse proxy that adds TLS, connection limits and a timeout on reading headers, and keep --rpc-addr on a private address. The proxy also decides whether /metrics and /health are public, since they share the port with /v1.
What the node itself enforces:
| Limit | Value |
|---|---|
| Request timeout, on every route | 30 s, then HTTP 408 |
| Request body | 590,848 bytes (2 * MAX_TX_BYTES + 1024, because hex doubles the size) |
| Submissions and views running at once | 32, taken after the body has arrived, so slow clients can't sit on the permits |
| WebSocket connections | 1,024 unless --ws-limit says otherwise, then HTTP 503 |
| CORS | permissive |
POST /v1/contracts/{address}/view runs code for free, so it shares the permits. Routes are on the API page.
/v1/ws needs the proxy to pass WebSocket upgrades, and the proxy's TLS is what makes the address wss://. With nginx:
location /v1/ws {
proxy_pass http://127.0.0.1:8545;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
--ws-limit counts all clients together, so one client could take every connection. Limit connections per address at the proxy.
Clocks
A block with a timestamp more than MAX_CLOCK_DRIFT_MS, 10,000 ms, ahead of the verifier's clock is refused, and timestamps are what valid_until_ms and vault delays are measured against. Keep validators' clocks in sync: a clock more than 10 s behind the proposer's stops that validator from voting on its blocks.
Resources
There are no published hardware requirements. What follows is computed from the code or was measured once, as stated.
Measured
Four validators on one machine, over loopback TLS: Apple M5 Max, 18 cores, 48 GiB. Release build, default flags, max_block_transactions 1,000. 9,600 transfers from 80 accounts were submitted over HTTP as fast as the API took them, a short burst and not a sustained rate.
| Quantity | Value |
|---|---|
| Blocks | 16 |
| Time to an empty mempool | 4.18 s, or 2,298 tx/s |
| Resident memory of one validator | 339 MB, sampled during the run |
| Encoded transactions | 33,468,960 bytes |
| Data directory right after the run | 82,744 KiB, of which 69 MB was RocksDB's write-ahead log |
| Same directory after one restart | 34,692 KiB |
The write-ahead log holds everything written, pending_blocks included, until RocksDB flushes it.
Sizes for planning
| Item | Bytes | Source |
|---|---|---|
| Transfer | 3,470 | measured |
| An account's first transaction, with its policy | 5,432 | measured |
| Empty block, 4 voters | 170 | measured |
| Kept proof, 4 validators | 10,123 | measured |
| Kept proof, 50 validators | 113,011 | formula |
| Vote on the wire | 3,367 | measured |
| Account that has only received | 46 | measured |
| Account with one revealed ML-DSA-65 key | 2,008 | measured |
Those give, before database overhead:
| Case | Size |
|---|---|
| 7 days of blocks at 100 transfers/s | 209.9 GB |
| 7 days of blocks at 10 transfers/s | 21.0 GB |
| 7 days of kept proofs, 50 validators, 10 blocks/s | 10.7 GB |
| Checkpoint proofs kept forever, 50 validators, 10 blocks/s | 11.9 MB per day |
An idle chain makes no blocks, so these are rates under load only. --history-keep bounds blocks and proofs, --prune-keep bounds old state, and an archive node with neither keeps growing.
Memory bounds
| Bound | Value |
|---|---|
| Mempool | 10,000 transactions of up to 288 KiB each, 2.75 GiB at worst |
| Signature cache | 400,000 32-byte entries |
| P2P send queue, per peer | 256 MiB |
| P2P received and unhandled, per peer | 2 MiB, then the peer is not read from |
| Blocks waiting for parents, per validator | 64 MiB |
systemd
The flags below were run on a real node with other paths. The unit itself has not been loaded by systemd.
[Unit]
Description=Qubits validator
After=network-online.target
Wants=network-online.target
[Service]
User=qubits
Environment=RUST_LOG=info
ExecStart=/usr/local/bin/qubits-node run \
--data-dir /var/lib/qubits \
--validator-key /etc/qubits/validator.json \
--password-file /etc/qubits/password \
--rpc-addr 10.0.0.1:8545 \
--p2p-addr 10.0.0.1:9545 \
--peer 1=10.0.0.2:9545 \
--peer 2=10.0.0.3:9545 \
--peer 3=10.0.0.4:9545 \
--follow http://10.0.0.2:8545 \
--follow http://10.0.0.3:8545 \
--follow http://10.0.0.4:8545 \
--history-keep 7d
KillSignal=SIGINT
TimeoutStopSec=30
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
KillSignal=SIGINT is what makes a stop clean. Restart=on-failure covers exit 101, and a stop through systemd stays stopped.
Source
chain/crates/qubits-node/src/main.rschain/crates/qubits-node/src/lib.rschain/crates/qubits-node/src/validator.rschain/crates/qubits-node/src/mempool.rschain/crates/qubits-node/src/metrics.rschain/crates/qubits-node/src/app.rschain/crates/qubits-node/src/rpc.rschain/crates/qubits-node/src/history.rschain/crates/qubits-node/src/network.rschain/crates/qubits-node/src/p2p.rschain/crates/qubits-types/src/signatures.rschain/crates/qubits-types/src/lib.rschain/crates/qubits-execution/src/lib.rschain/crates/qubits-consensus/src/engine.rs