Part 1 of the Custom Geth Consensus Series
When Ethereum moved to Proof of Stake, Geth introduced the Engine API to split the execution layer from the consensus layer. It was built for beacon chain clients, but nothing stops you from putting your own consensus logic on the other end.
This series does exactly that. It starts with raw Engine API calls and ends with a distributed, fault-tolerant system. Full source code is on GitHub.
Why Build Custom Consensus?
- Layer 2 chains: Sequencers for rollups or app-specific chains
- Private networks: Permissioned chains with custom block production
- Research: Experimenting with new consensus mechanisms
The mev-commit project uses custom consensus to enable preconfirmations.
Architecture Overview
The Engine API uses HTTP with JWT authentication. Three primary methods:
| Method | Purpose |
|---|---|
ForkchoiceUpdated | Set chain head, trigger block building |
GetPayload | Retrieve a built block |
NewPayload | Submit a block for execution |
Running Geth
Clone the repo and start Geth:
git clone https://github.com/mikelle/geth-consensus-tutorial.git
cd geth-consensus-tutorial
docker compose up geth
This starts Geth with the Engine API on port 8551 and HTTP RPC on 8545. The setup uses a custom genesis.json for the private chain. See docker-compose.yml and geth-entrypoint.sh for the full configuration.
One important flag: --miner.gasprice 1. Geth’s miner filters transactions whose effective tip is below this threshold during block building. The default (1 Mwei) silently drops transactions with low priority fees. This is a common gotcha on private chains where tools like cast default to minimal fees.
Once Geth is running, start the consensus client from the repo:
cd 01-engine-api
go run main.go
JWT Authentication
Geth’s Engine API requires JWT auth. A 32-byte shared secret is included in the repo at jwt/jwt.hex. To generate your own:
openssl rand -hex 32 > jwt/jwt.hex
Wrap HTTP requests with a JWT token:
// jwt.go
package consensus
import (
"bytes"
"encoding/hex"
"net/http"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
type jwtRoundTripper struct {
underlyingTransport http.RoundTripper
jwtSecret []byte
}
func (t *jwtRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// Fresh token per request - avoids expiration issues
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iat": time.Now().Unix(),
})
tokenString, err := token.SignedString(t.jwtSecret)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+tokenString)
return t.underlyingTransport.RoundTrip(req)
}
func LoadJWTHexFile(file string) ([]byte, error) {
jwtHex, err := os.ReadFile(file)
if err != nil {
return nil, err
}
jwtHex = bytes.TrimSpace(jwtHex)
jwtHex = bytes.TrimPrefix(jwtHex, []byte("0x"))
return hex.DecodeString(string(jwtHex))
}
Error handling is shown in full above. Remaining code samples omit it for brevity. See the repo for complete implementations.
Engine Client
Wrap the Engine API calls. These use types from Geth’s engine, common, and hexutil packages:
// engineclient.go
type EngineClient struct {
rpc *rpc.Client
}
func (c *EngineClient) ForkchoiceUpdatedV3(ctx context.Context, state engine.ForkchoiceStateV1,
attrs *engine.PayloadAttributes) (engine.ForkChoiceResponse, error) {
var resp engine.ForkChoiceResponse
err := c.rpc.CallContext(ctx, &resp, "engine_forkchoiceUpdatedV3", state, attrs)
return resp, err
}
func (c *EngineClient) GetPayloadV5(ctx context.Context, payloadID engine.PayloadID) (*engine.ExecutionPayloadEnvelope, error) {
var resp engine.ExecutionPayloadEnvelope
err := c.rpc.CallContext(ctx, &resp, "engine_getPayloadV5", payloadID)
return &resp, err
}
func (c *EngineClient) NewPayloadV4(ctx context.Context, payload engine.ExecutableData,
versionedHashes []common.Hash, beaconRoot *common.Hash,
requests []hexutil.Bytes) (engine.PayloadStatusV1, error) {
var resp engine.PayloadStatusV1
err := c.rpc.CallContext(ctx, &resp, "engine_newPayloadV4", payload, versionedHashes, beaconRoot, requests)
return resp, err
}
func NewEngineClient(ctx context.Context, endpoint string, jwtSecret []byte) (*EngineClient, error) {
transport := &jwtRoundTripper{
underlyingTransport: http.DefaultTransport,
jwtSecret: jwtSecret,
}
httpClient := &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
}
rpcClient, _ := rpc.DialOptions(ctx, endpoint, rpc.WithHTTPClient(httpClient))
return &EngineClient{rpc: rpcClient}, nil
}
Block Building Lifecycle
Building a block is two-phase:
Phase 1: Propose
Phase 2: Finalize
Implementation:
type ExecutionHead struct {
BlockHeight uint64
BlockHash []byte
BlockTime uint64
}
func (bb *BlockBuilder) GetPayload(ctx context.Context) (*engine.ExecutableData, []hexutil.Bytes, error) {
timestamp := uint64(time.Now().Unix())
if timestamp <= bb.executionHead.BlockTime {
timestamp = bb.executionHead.BlockTime + 1
}
headHash := common.BytesToHash(bb.executionHead.BlockHash)
// In a single-node setup, head/safe/finalized point to the same block.
// Multi-node consensus (Part 3+) will differentiate these.
fcs := engine.ForkchoiceStateV1{
HeadBlockHash: headHash,
SafeBlockHash: headHash,
FinalizedBlockHash: headHash,
}
attrs := &engine.PayloadAttributes{
Timestamp: timestamp,
SuggestedFeeRecipient: bb.feeRecipient,
BeaconRoot: &headHash,
Withdrawals: []*types.Withdrawal{},
// Random is the RANDAO mix from PoS. Not meaningful for custom
// consensus, but the field is required, so any deterministic value works.
Random: headHash,
}
// Start block building
response, _ := bb.engineCl.ForkchoiceUpdatedV3(ctx, fcs, attrs)
payloadID := response.PayloadID
// Wait for Geth to include transactions
time.Sleep(bb.buildDelay)
// Retrieve built block
payloadResp, _ := bb.engineCl.GetPayloadV5(ctx, *payloadID)
// Convert execution requests for NewPayloadV4
requests := make([]hexutil.Bytes, len(payloadResp.Requests))
for i, r := range payloadResp.Requests {
requests[i] = r
}
return payloadResp.ExecutionPayload, requests, nil
}
Finalization:
func (bb *BlockBuilder) FinalizeBlock(ctx context.Context,
payload *engine.ExecutableData, requests []hexutil.Bytes) error {
// Validate
if payload.Number != bb.executionHead.BlockHeight+1 {
return fmt.Errorf("invalid height")
}
if payload.ParentHash != common.BytesToHash(bb.executionHead.BlockHash) {
return fmt.Errorf("invalid parent")
}
// Submit to Geth
parentHash := common.BytesToHash(bb.executionHead.BlockHash)
status, _ := bb.engineCl.NewPayloadV4(ctx, *payload, []common.Hash{}, &parentHash, requests)
if status.Status == engine.INVALID {
return fmt.Errorf("payload invalid: %s", *status.ValidationError)
}
// Update fork choice
fcs := engine.ForkchoiceStateV1{
HeadBlockHash: payload.BlockHash,
SafeBlockHash: payload.BlockHash,
FinalizedBlockHash: payload.BlockHash,
}
bb.engineCl.ForkchoiceUpdatedV3(ctx, fcs, nil)
// Update local head
bb.executionHead = &ExecutionHead{
BlockHeight: payload.Number,
BlockHash: payload.BlockHash.Bytes(),
BlockTime: payload.Timestamp,
}
return nil
}
Payload Status
| Status | Meaning | Action |
|---|---|---|
VALID | Payload executed | Continue |
INVALID | Validation failed | Don’t retry |
SYNCING | Geth syncing | Retry later |
ACCEPTED | Accepted, not validated | Wait |
State Management
Track consensus state for restarts:
type BuildStep int
const (
StepBuildBlock BuildStep = iota
StepFinalizeBlock
)
type BlockBuildState struct {
CurrentStep BuildStep
PayloadID string
ExecutionPayload string // Base64-encoded
}
State machine:
What’s Next
Part 2: Single Node Consensus builds a complete implementation with full application structure, retry logic, metrics, and configuration management. Part 3: Distributed Consensus adds Redis leader election, PostgreSQL storage, and horizontally scalable member nodes. Part 4: CometBFT Integration replaces custom leader election with BFT consensus for instant finality and multi-validator voting.
Full source code: geth-consensus-tutorial | Based on mev-commit consensus layer