Sakhali is a minimal external consensus client for an EVM chain backed by a separate execution-layer process.
The current shape is a simple PoS-flavored qBFT client:
- a static validator set defines who can propose and vote
- the proposer schedule is round-robin over that validator set by
height, round - the proposer assembles an execution payload from Erigon, reconstructs the block locally, and runs qBFT over the payload hash before insertion
- validator and non-validator nodes both follow the chain by accepting committed blocks and updating forkchoice
- libp2p gossip pubsub carries protobuf-encoded consensus messages, and a libp2p stream protocol handles protobuf sync catch-up
Erigon internals still provide the execution engine. Sakhali uses the execution-facing operations an external consensus layer needs:
AssembleBlock/GetAssembledBlockCurrentHeaderInsertBlocksValidateChainUpdateForkChoice
internal/el: separate execution-layer runtime and gRPC server exposingexecution.Executioninternal/engine: thin execution gRPC client adapter used by the consensus processinternal/consensus: validator set, qBFT round state, and the orchestration serviceinternal/builder: payload-to-block reconstruction and timestamp validationinternal/store: Pebble-backed durable stateinternal/p2p: libp2p host + gossipsub transport with mDNS/static peer bootstrapinternal/gen/sakhali/v1: protobuf code generated bybufinternal/app: process configuration and flag parsing
The responsibilities are intentionally separated:
- the engine adapter only talks to Erigon
- the builder only turns an execution payload into a local candidate block and checks that it matches the expected parent/number/timestamp window
- qBFT reasons about proposer validity, votes, round changes, quorum, and commit decisions
- the service coordinates block production, durable vote state, sync catch-up, insertion, validation, and forkchoice updates
- transport signs every qBFT protobuf message explicitly and verifies that signature against the pubsub author on receipt
- Erigon exposes the execution gRPC service on an internal address such as
127.0.0.1:9092 - validator identities are libp2p peer IDs configured in proposer order
- validator count satisfies qBFT's
3f+1sizing, for example4or7 - block timestamps are treated as millisecond-precision values, so sub-second periods such as
500msare allowed - consensus state is persisted locally in Pebble and reloaded on restart
- payload validity before consensus is limited to local reconstruction and structural checks before final execution validation after commit
Start the execution-layer node:
go run ./cmd/sakhali-el \
--data-dir data/execution \
--genesis infrastructure/deployment/generated/localnet/genesis.json \
--grpc.addr 127.0.0.1:9092Then start the consensus node:
go run ./cmd/sakhali \
--execution.addr 127.0.0.1:9092 \
--consensus.block-period 500ms \
--consensus.validators <peer-id-1>,<peer-id-2>,<peer-id-3>,<peer-id-4> \
--p2p.private-key <hex-encoded-libp2p-private-key>The EL process boots the execution runtime, serves app-facing JSON-RPC and websocket APIs, and exposes the execution.Execution gRPC service on --grpc.addr. The consensus process waits for that gRPC service to report ready, then boots libp2p and starts proposing or validating blocks depending on whether its peer ID is in the validator set.
Wire and persistence schemas live under proto/. Regenerate Go code with:
buf generateLocal deployment assets live under infrastructure/deployment.
The first packaged environment is a Docker-based localnet with:
4validator nodes1non-validator follower- one
sakhaliconsensus container and one execution-layer container per node
See infrastructure/deployment/README.md for the generator, Docker image build, and docker compose workflow.
Consensus (cmd/sakhali):
--execution.addr: Erigon execution gRPC address--consensus.validators: ordered validator peer IDs used for proposer rotation and vote quorum--consensus.block-period: minimum block spacing used when proposing new payloads, including sub-second values like500ms--consensus.round-timeout: base timeout before broadcasting a round change--consensus.round-timeout-step: extra timeout added per higher round--consensus.state-dir: Pebble directory for durable consensus state--consensus.sync-batch-size: number of committed blocks sent per streamed sync batch--consensus.fee-recipient: fee recipient for locally assembled payloads--poll-interval: retry interval for gRPC busy responses--ready-poll-interval: retry interval while waiting for Erigon to become ready--validation-timeout: timeout forValidateChain--forkchoice-timeout: timeout Erigon should spend on a forkchoice request before returning busy--p2p.topic: gossip topic for qBFT messages--p2p.discovery:mdns,static, ornone--p2p.listen-addrs: comma-separated libp2p listen multiaddrs--p2p.static-peers: comma-separated static peers to dial on startup--p2p.private-key: hex-encoded private key bytes for a stable peer ID
Execution (cmd/sakhali-el):
--data-dir: execution-layer datadir--genesis: path to the genesis JSON file--grpc.addr: gRPC address exposingexecution.Execution--http.addr,--http.port,--http.api: public JSON-RPC listener settings--ws,--ws.port: websocket RPC settings--authrpc.addr,--authrpc.port,--authrpc.jwtsecret: authenticated Engine API settings--p2p.listen-addr,--p2p.max-peers,--p2p.max-pending-peers,--p2p.no-discovery: execution-layer devp2p settings
- qBFT currently runs as
proposal -> prepare -> commit, with timeout-driven round changes - proposing is event-driven: the next proposer is scheduled immediately after finalization or round advancement, and only waits for the block timestamp window when necessary
- the proposal digest is the reconstructed execution payload block hash
- consensus and sync messages are protobuf, not JSON
- blocks are inserted only after commit quorum is reached
- committed payloads are stored locally and served to peers through a dedicated libp2p sync stream
- restart recovery first replays local committed payloads, then opens a sync stream to peers and applies streamed committed batches until caught up
- final execution validity still comes from
ValidateChainandUpdateForkChoiceafter insertion - future work should add stronger qBFT prepared-certificate handling, stronger identity/authentication, and more robust discovery beyond local mDNS/static peers