Validator networking
Validators talk over direct TCP connections, one per pair, encrypted with TLS 1.3 using only the hybrid X25519MLKEM768 key exchange and authenticated by an ML-DSA signature over the TLS exporter. There is no discovery and no libp2p, because a fixed validator set needs neither. This page specifies the transport for node operators and auditors: connection setup, the handshake byte by byte, frames, limits, and transaction gossip. What travels over it is on Consensus, and followers use HTTP as described on Sync.
Path of a message
Engine output
| Validator::act: encode a NetMessage, send directly or as shards
v
Transport::send or broadcast
| Outgoing::push: per-peer queue, 4096 frames, 256 MiB
v
run_connection: write_frame, TLS, TCP
|
v
read_frame on the peer: at most 16 MiB, paused above 2 MiB unhandled
|
v
Inbox, then Validator::handle on the consensus thread
Transport
pub trait Transport: Send + Sync + 'static {
fn send(&self, to: &PublicKey, bytes: Vec<u8>);
fn broadcast(&self, bytes: Vec<u8>);
fn connected(&self) -> usize {
0
}
}
pub type Envelope = (PublicKey, Frame);
pub type Inbox = Receiver<Envelope>;
Peers are named by public key and not by ValidatorIndex, because indices change between epochs. Both calls return nothing and never block. Delivery is best effort, and consensus is built to tolerate loss.
| Implementation | Used for | Behaviour |
|---|---|---|
TcpTransport | Validators with peers | The rest of this page |
NoPeers | A single validator with no --peer | Drops everything, connected is 0. No listener opens |
LocalTransport, from LocalHub | In-process tests | Unbounded channels between members. LocalHub::new(&members) returns the hub and one Inbox per member, hub.transport(&key) gives a member its transport, and set_down(&key, true) drops everything to and from a member |
A Frame holds the received bytes and dereferences to [u8]. Dropping it releases its bytes from the sender's unhandled count.
Configuration
| Flag | Default | Meaning |
|---|---|---|
--p2p-addr | 0.0.0.0:9545 | Address to accept validator connections on |
--peer KEY=HOST:PORT | none | Another validator. Repeat for each |
KEY is read in this order:
- If it parses as a decimal number, it is a position in the genesis
validators. This is always the genesis list, not the current set. - Otherwise it is the hex public key that
qubits key showprints, with its scheme byte.
HOST must be an IP address. The value is parsed as a SocketAddr, so a host name fails with HOST:PORT must be an IP address and port.
The --peer list is the whole access list. It is fixed when the process starts, it may contain this node's own key, and it should contain validators expected to join later. A key outside the list cannot complete a handshake and has no send queue. At startup the node exits with N current validators have no --peer address if any current validator is missing. See Validators and epochs.
A node with no validator key opens no validator connections.
p2p::connect builds an Identity from the genesis hash, the validator key and the keys in --peer, binds the listener, and returns the transport, the inbox and the bound address.
Connections
There is one TCP connection per pair. The lower key dials the higher one, and an acceptor refuses a dialer whose key is higher than its own. PublicKey orders by scheme and then by raw bytes. The rule means two validators never race to open two connections.
For each peer the node runs one task that owns that peer's send queue:
- If this node dials, it loops over
dial. After a failure it waits, starting at 100 ms, doubling up to 5 s, and the wait resets once connected. - If this node accepts, it waits for the accept loop to hand it an authenticated stream, through a channel of 4.
- While connected,
run_connectionwrites queued frames and a separate task reads. It ends when a write fails, when the read task ends, or when a newer accepted stream arrives. In the last case the task switches to the new stream and drops the old one.
The send queue belongs to the task, not to the connection, so frames queued while a peer is away go out when it returns. Both sides set TCP_NODELAY. connected() counts live connections and is what qubits_peers and the status line report.
TLS
| Property | Value |
|---|---|
| Library | rustls 0.23 with the aws-lc-rs provider |
| Protocol | TLS 1.3 only |
| Key exchange | X25519MLKEM768 only |
| Cipher suites | TLS13_AES_256_GCM_SHA384, TLS13_CHACHA20_POLY1305_SHA256 |
| Server name | qubits-validator |
| Server certificate | Self-signed, generated once per process start |
| Client certificate | None |
The crypto provider lists only that key-exchange group, so a peer that does not offer it fails the TLS handshake in either direction. The tests a_client_without_the_hybrid_key_exchange_is_refused and a_server_without_the_hybrid_key_exchange_is_refused cover both.
Certificates are throwaway. rcgen generates one with its default key type, ECDSA P-256, and no trust rests on it: ExporterAuthenticated, the client's verifier, accepts any certificate. It still checks the TLS 1.3 CertificateVerify signature against the presented certificate, and it refuses TLS 1.2 signatures. Identity comes only from the hello that follows.
Handshake
Both sides run these structures after the TLS handshake:
struct Hello {
key: PublicKey,
signature: Signature,
}
struct HelloBytes<'a> {
tag: &'static [u8], // b"qubits p2p hello v1"
chain: &'a Hash, // the genesis hash
dialer: bool,
key: &'a PublicKey,
exporter: &'a [u8; 32],
}
The whole exchange runs under HANDSHAKE_TIMEOUT (10 s):
- The dialer connects over TCP and completes TLS.
- Both sides derive a 32-byte
exporterwithexport_keying_material, labelEXPORTER-qubits-p2p-v1, no context. - The dialer signs
HelloBytes { dialer: true, .. }with its validator key underDomain::Handshake, FIPS 204 contextqubits/handshake/v1, and sends aHelloframe. - The acceptor reads one frame of at most
MAX_HELLO_BYTES(8 KiB) and decodes it. - The acceptor checks that
hello.keyis in its--peerlist. Only then does it spend time on the signature. - The acceptor verifies the signature with
dialer: true, its own view ofchain, and its ownexporter. - The acceptor refuses the connection if the peer's key is higher than its own.
- The acceptor sends its own
Hellowithdialer: false. - The dialer reads it, verifies it the same way with
dialer: false, and requires the key to be the peer it meant to reach. - The stream goes to that peer's task.
What each signed field stops:
| Field | What it stops |
|---|---|
exporter | Relaying. A party in the middle holds two TLS sessions with different exporters, so a hello from one fails on the other |
chain | A key's hello from another chain |
dialer | Reflecting a hello back to its signer in the other role |
key | Claiming a signature for another key |
tag and the domain | Using the signature as a consensus, transaction or message signature |
Byte layout of the signed message with an ML-DSA-65 key, 2045 bytes in all. Borsh integers are little-endian.
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | Tag length, 19 |
| 4 | 19 | qubits p2p hello v1 |
| 23 | 32 | chain |
| 55 | 1 | dialer, 1 or 0 |
| 56 | 4 | Key length, 1953 |
| 60 | 1 | Scheme byte, 0x01 |
| 61 | 1952 | Raw public key |
| 2013 | 32 | exporter |
A Hello is 5271 bytes: 1957 for the key and 3314 for the signature, with Vec<u8> length prefixes. It goes in one frame, 5275 bytes with the frame prefix.
| Failure | Error | Side |
|---|---|---|
| Hello over 8 KiB | frame too large | Reader |
Not a Hello | malformed hello | Reader |
Key not in --peer | not a configured validator | Reader |
| Bad signature, other chain, other session | bad hello signature | Reader |
| Higher key dialed a lower one | validator should not dial us | Acceptor |
| A different validator answered | connected to a different validator than expected | Dialer |
| Over 10 s | handshake timed out | Both |
The acceptor logs these at debug level as refused connection, the dialer as dial failed.
Frames
Everything after TLS is framed. This length prefix is big-endian, unlike borsh integers.
offset size field
0 4 length, u32 big-endian
4 length payload
The payload is a Hello during the handshake and a NetMessage afterwards. write_frame flushes after every frame. read_frame compares the length with its limit before allocating. A frame over the limit ends the read loop, and with it the connection. A frame within the limit that does not decode is dropped with a debug log, and the connection stays.
| Limit | Value | Applies to |
|---|---|---|
MAX_HELLO_BYTES | 8 KiB | Handshake frames |
MAX_MESSAGE_BYTES | 16 MiB | Frames after the handshake, and NetMessage::decode |
For scale, MAX_BLOCK_BYTES is 8 MiB and MAX_TX_BYTES is 288 KiB.
Messages
pub enum NetMessage {
Consensus { epoch: u64, message: Message<BlockPayload> },
Transaction(SignedTransaction),
Shard { epoch: u64, shard: Shard },
}
| Tag | Variant | Encoding | Sent by |
|---|---|---|---|
| 0 | Consensus | epoch as u64, then a Message with its own one-byte tag | Validators, directly |
| 1 | Transaction | A SignedTransaction | Validators, as gossip |
| 2 | Shard | epoch as u64, then a Shard | Proposers and relayers |
The envelope adds 9 bytes to a consensus message. A vote is 3,367 bytes in its envelope, and a frame of it 3,371 bytes. Routing by epoch is on Validators and epochs.
Limits on what a peer can cost
Before authentication
The listener accepts anyone, so a connection must take two slots before any work is done on it. If either is unavailable, the socket is closed at once, before TLS.
| Constant | Value | Meaning |
|---|---|---|
MAX_HANDSHAKES | 64 | Connections in handshake, in total |
MAX_HANDSHAKES_PER_ADDRESS | 4 | Connections in handshake, per IP address |
HANDSHAKE_TIMEOUT | 10 s | How long a slot can be held |
MAX_HELLO_BYTES | 8 KiB | All an unauthenticated peer may send after TLS |
ACCEPT_BACKOFF | 100 ms | Pause after accept itself fails |
Slots are released as soon as the handshake ends, either way, so established connections hold none. A signature is verified only for a key on the --peer list. An attacker that keeps all 64 slots filled from many addresses delays a validator's reconnection for as long as it does so. It does not touch established connections.
Reading
An authenticated peer is not read from while more than MAX_UNHANDLED_BYTES (2 MiB) of what it sent waits to be handled. The read task adds each frame's size to a counter for that connection, passes the frame to the inbox, and polls every 5 ms until the counter is back at or below the limit. Dropping a Frame after Validator::handle subtracts its size.
The limit is checked after a frame is read, so a peer can hold up to 2 MiB plus one frame, 18 MiB at most. TCP then pushes back on the sender. The inbox channel itself is unbounded, and this count is what bounds it, one sender at a time.
Sending
Each peer has a queue limited in frames and in bytes. Outgoing::push drops a frame when either limit is reached. Consensus never waits for a slow peer, and what is dropped is recovered through timeouts, Sync and block requests.
| Constant | Value | Meaning |
|---|---|---|
QUEUE | 4096 | Frames waiting per peer |
MAX_QUEUED_BYTES | 256 MiB | Bytes waiting per peer |
There is one queue per peer, first in first out, with no priority between kinds of message.
Gossip
Transactions spread in one hop, plus retries:
- A node that accepts a transaction through
POST /v1/transactionsbroadcasts it to all peers asNetMessage::Transaction.Validator::startregisters the transport for this. - A receiver pushes it into
Intake, a queue ofINTAKE_QUEUE(1024) read by its own thread,tx-intake. Signature checks are too slow for the consensus thread. When the queue is full the transaction is dropped. - The thread calls
Node::accept, which adds it to the mempool or refuses it. Accepting does not broadcast again. - Every
regossip_ms(3 s),Validator::tickexpires old transactions and rebroadcasts up to 64 of the oldest that have waited at least 3 s.
| Setting | Value | Source |
|---|---|---|
regossip_ms | 3000 ms | Timing::DEFAULT |
| Transactions per re-gossip | 64 | Validator::tick |
tx_ttl_ms | 600,000 ms | Timing::DEFAULT |
MAX_MEMPOOL_TRANSACTIONS | 10,000 | mempool.rs |
MAX_PER_SENDER | 64 | mempool.rs |
A transaction also expires at its valid_until_ms. Gossip carries no epoch and is accepted from any authenticated peer. A duplicate ends in SubmitError::Duplicate.
API nodes are not on this network. They pass submitted transactions to their --follow sources over HTTP, and repeat that every 3 s for up to 64 waiting transactions. See Sync.
Large proposals also travel as NetMessage::Shard, described on Consensus.
Observing it
| Signal | Where |
|---|---|
validator connected, validator disconnected | Info log, per peer |
accepting validator connections on ADDR | Info log, at startup |
dial failed, refused connection | Debug log |
| Every message sent and received, by type, round and size | RUST_LOG=qubits_node=debug |
peers, in_msgs, in_kb, out_msgs, out_kb | status log line, every 10 s |
qubits_peers, qubits_messages_in_total, qubits_bytes_in_total, qubits_messages_out_total, qubits_bytes_out_total, qubits_shards_rebuilt_total | /metrics |
Tests
| Test | What it shows |
|---|---|
handshake_uses_hybrid_pq_key_exchange_and_authenticates_both_sides | The negotiated group, version and cipher suite, and both identities |
a_client_without_the_hybrid_key_exchange_is_refused | A classical client fails |
a_server_without_the_hybrid_key_exchange_is_refused | A classical server fails |
outsiders_and_impersonators_are_refused | Unknown key, key from another chain |
dialing_the_wrong_validator_fails | The dialer checks whom it reached |
the_higher_key_may_not_dial_the_lower_one | Direction of dialing |
a_hello_may_not_be_large, oversized_frames_are_refused | Frame limits |
handshakes_are_limited_per_address | Per-address slots |
the_send_queue_is_limited_in_bytes | Queue limit |
four_validators_reach_consensus_over_tcp | A real network on loopback, with leaders rotating |
random_bytes_never_panic_any_decoder, mutated_network_messages_and_blocks_decode_safely, oversized_inputs_are_refused_before_decoding | Hostile input to the decoders |
chain/crates/qubits-e2e/e2e.py runs real processes through TCP proxies that add delay and cut links. Its byzantine phase uses a validator that passes the handshake with its real key and then sends only rubbish.
Source
chain/crates/qubits-node/src/p2p.rschain/crates/qubits-node/src/network.rschain/crates/qubits-node/src/validator.rschain/crates/qubits-node/src/lib.rschain/crates/qubits-node/src/mempool.rschain/crates/qubits-node/src/main.rschain/crates/qubits-node/src/metrics.rschain/crates/qubits-crypto/src/scheme.rschain/crates/qubits-node/tests/p2p.rschain/crates/qubits-node/tests/robustness.rs