Skip to content

3.5 Code Lodestar Block Building

Receive Gossip Topic beacon_block or beacon_block_and_blobs_sidecar#

Code: https://github.com/ChainSafe/lodestar/blob/v1.5.1/packages/beacon-node/src/network/gossip/handlers/index.ts L:167

export function getGossipHandlers (modules: ValidatorFnsModules, options: GossipHandlerOpts): GossipHandlers {
  const {chain, config, metrics, network, logger} = modules;
  return {
    [GossipType.beacon_block]: async (signedBlock, topic, peerIdStr, seenTimestampSec) => {
      // ...
      /* explained in 'Process The Block' below */
      handleValidBeaconBlock()
    },
    [GossipType.beacon_block_and_blobs_sidecar]: async (blockAndBlocks, topic, peerIdStr, seenTimestampSec) => {
      /* will be activated since deneb upgrade */
      /* explained in 'Process The Block' below */
      handleValidBeaconBlock()
    }
  }
}

The topic has upgraded from beacon_block to beacon_block_and_blobs_sidecar with the deneb upgrade. As the name indicates, the new topic would expect also 'blobs_sidecar' in the payload. 'blobs_sidecar' means some extra arbitrary data that get included in the beacon chain but not sent to the execution engine. Since it saves the computation of execution engine, it is saves gas cost for Layer-2's, which only want to store 'rolled up' data to Layer-1 Ethereum.

It's also important to note that if the block is verified, the consensus client will eventually send JSON-RPC call engine_newPayload.

Spec References

The block and sidecar is introduced in deneb to include EIP-4844.

Process The Block#

If the payload is verified, it goes on to be included in the beacon chain. Below is simplified code based on https://github.com/ChainSafe/lodestar/blob/v1.5.1/packages/beacon-node/src/chain/blocks/index.ts:

  function handleValidBeaconBlock(blockInput: BlockInput, peerIdStr: string, seenTimestampSec: number): void {
    chain
      .processBlock(blockInput, {
        // ...
      })
      .then(() => {
        // ...
        /* metrics logging */
      })
      .catch((e) => {
        // ...
      });
  }

Where chain is BeaconChain instance, refer to 3.1 Code - Lodestar - CLI Beacon Node#Create chain.

async processBlock(block: BlockInput, opts?: ImportBlockOpts): Promise<void> {
  /* 
  blockProcessor queues the jobs 
  and exposes function processBlocksJob,
  which calls async function processBlocks.
  */
  return await this.blockProcessor.processBlocksJob([block], opts);
}

The queue is configured with QUEUE_MAX_LENGTH = 256. If the node is too much out of sync, it will throw an error. Otherwise if it does not exceed maxConcurrency, it will run the job.

More about processBlock:

export async function processBlocks(
  this: BeaconChain,
  blocks: BlockInput[],
  opts: BlockProcessOpts & ImportBlockOpts
): Promise<void> {
  if (blocks.length === 0) {
    return; // TODO: or throw?
  } else if (blocks.length > 1) {
    /* Here chainSegment just means multiple blocks */
    assertLinearChainSegment(this.config, blocks);
  }

  try {
    /* 
    I think verifyBlocksSanityChecks is a bad name here.
    Its better called 'sanitizeBlocks'.
    It does 'some early cheap sanity checks on the block'
    and returns filtered objects.
    */
    const {relevantBlocks, parentSlots, parentBlock} = verifyBlocksSanityChecks(this, blocks, opts);

    // No relevant blocks, skip verifyBlocksInEpoch()
    if (relevantBlocks.length === 0 || parentBlock === null) {
      // parentBlock can only be null if relevantBlocks are empty
      return;
    }

    /* 
    This is the core logic which interacts with the execution engine,
    therefore it is able to get:
    - postStates, BeaconState after the block
    - proposerBalanceDeltas, the execution result of transactions
    - segmentExecStatus
    Will go into more detail later.
    */
    // Fully verify a block to be imported immediately after. Does not produce any side-effects besides adding intermediate
    // states in the state cache through regen.
    const {postStates, proposerBalanceDeltas, segmentExecStatus} = await verifyBlocksInEpoch.call(
      this,
      parentBlock,
      relevantBlocks,
      opts
    );

    /* 
    This is a typo and it should be 'LVH', for latest valid hash.
    It's fixed in later versions already: https://github.com/ChainSafe/lodestar/pull/5850
    This article explains well why LVH: 
    https://docs.prylabs.network/docs/how-prysm-works/optimistic-sync

    If there is an execution engine error
    and the latest hash is not what we expect,
    it is going to be a fork choice.
    */
    // If segmentExecStatus has lvhForkchoice then, the entire segment should be invalid
    // and we need to further propagate
    if (segmentExecStatus.execAborted !== null) {
      if (segmentExecStatus.invalidSegmentLHV !== undefined) {
        this.forkChoice.validateLatestHash(segmentExecStatus.invalidSegmentLHV);
      }
      /* stop further processing */
      throw segmentExecStatus.execAborted.execError;
    }

    /* decorate 'fullyVerifiedBlocks' with extra data from execution */
    const {executionStatuses} = segmentExecStatus;
    const fullyVerifiedBlocks = relevantBlocks.map(
      (block, i): FullyVerifiedBlock => ({
        blockInput: block,
        postState: postStates[i],
        parentBlockSlot: parentSlots[i],
        executionStatus: executionStatuses[i],
        proposerBalanceDelta: proposerBalanceDeltas[i],
        // TODO: Make this param mandatory and capture in gossip
        seenTimestampSec: opts.seenTimestampSec ?? Math.floor(Date.now() / 1000),
      })
    );

    /* 
    If everything is good, add the block to the local chain.
    Will go into further detail later.
    */
    for (const fullyVerifiedBlock of fullyVerifiedBlocks) {
      // No need to sleep(0) here since `importBlock` includes a disk write
      // TODO: Consider batching importBlock too if it takes significant time
      await importBlock.call(this, fullyVerifiedBlock, opts);
    }
  } catch (e) {
    // above functions should only throw BlockError
    const err = getBlockError(e, blocks[0].block);

    // TODO: De-duplicate with logic above
    // ChainEvent.errorBlock
    if (!(err instanceof BlockError)) {
      this.logger.error("Non BlockError received", {}, err);
    } else if (!opts.disableOnBlockError) {
      this.logger.error("Block error", {slot: err.signedBlock.message.slot}, err);

      if (err.type.code === BlockErrorCode.INVALID_SIGNATURE) {
        const {signedBlock} = err;
        const blockSlot = signedBlock.message.slot;
        const {state} = err.type;
        const forkTypes = this.config.getForkTypes(blockSlot);
        this.persistInvalidSszValue(forkTypes.SignedBeaconBlock, signedBlock, `${blockSlot}_invalid_signature`);
        this.persistInvalidSszView(state, `${state.slot}_invalid_signature`);
      } else if (err.type.code === BlockErrorCode.INVALID_STATE_ROOT) {
        const {signedBlock} = err;
        const blockSlot = signedBlock.message.slot;
        const {preState, postState} = err.type;
        const forkTypes = this.config.getForkTypes(blockSlot);
        const invalidRoot = toHex(postState.hashTreeRoot());

        const suffix = `slot_${blockSlot}_invalid_state_root_${invalidRoot}`;
        this.persistInvalidSszValue(forkTypes.SignedBeaconBlock, signedBlock, suffix);
        this.persistInvalidSszView(preState, `${suffix}_preState`);
        this.persistInvalidSszView(postState, `${suffix}_postState`);
      }
    }

    throw err;
  }
}

More about 'Optimistic Sync': https://hackmd.io/5NhsX8FvSm2GqESpdpe-Vg?view. Especially '2.2 The engine API' worths reading.

verifyBlocksInEpoch#

The main logic in https://github.com/ChainSafe/lodestar/blob/v1.5.1/packages/beacon-node/src/chain/blocks/verifyBlock.ts:

const [segmentExecStatus, {postStates, proposerBalanceDeltas}] = await Promise.all([
  // Execution payloads
  verifyBlocksExecutionPayload(this, parentBlock, blocks, preState0, abortController.signal, opts),
  // Run state transition only
  // TODO: Ensure it yields to allow flushing to workers and engine API
  verifyBlocksStateTransitionOnly(preState0, blocksInput, this.logger, this.metrics, abortController.signal, opts),

  // All signatures at once
  verifyBlocksSignatures(this.bls, this.logger, this.metrics, preState0, blocks, opts),
]);

verifyBlockExecutionPayload#

https://github.com/ChainSafe/lodestar/blob/v1.5.1/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts
verifyBlockExecutionPayload communicates to execution client:

const execResult = await chain.executionEngine.notifyNewPayload(
  chain.config.getForkName(block.message.slot),
  executionPayloadEnabled
);

It maps to engines API endpoint engine_newPayloadV3, engine_newPayloadV2 or engine_newPayloadV1, depending on which upgrade it is.

verifyBlocksStateTransitionOnly#

https://github.com/ChainSafe/lodestar/blob/v1.5.1/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts
verifyBlocksStateTransitionOnly runs the state transition and gets to know whether the block is valid. It calls stateTransition under the hood.

verifyBlocksSignatures#

https://github.com/ChainSafe/lodestar/blob/v1.5.1/packages/beacon-node/src/chain/blocks/verifyBlocksSignatures.ts

importBlock#

https://github.com/ChainSafe/lodestar/blob/v1.5.1/packages/beacon-node/src/chain/blocks/importBlock.ts

Blobs sidecar is handled differently if it is present (after deneb upgrade):

if (blockInput.type === BlockInputType.postDeneb) {
    const {blobs} = blockInput;
    // NOTE: Old blobs are pruned on archive
    await this.db.blobsSidecar.add(blobs);
    this.logger.debug("Persisted blobsSidecar to hot DB", {
      blobsLen: blobs.blobs.length,
      slot: blobs.beaconBlockSlot,
      root: toHexString(blobs.beaconBlockRoot),
    });
}

The code itself is very clearly commented. I copied the comments in function importBlock(L:47) here for easier reading and added some notes:
1. Persist block to hot DB (pre-emptively)
2. Import block to fork-choice.

Could also refer to pseudo code here.
3. Import attestations to fork-choice
4. Import attester slashings to fork-choice
5. Compute head. If new head, immediately stateCache.setHeadState().
It happens after step 3. since Ethereum consensus protocol says 'the branch with the most attestation wins'.
this.stateCache.setHeadState(headState) // 'this' is chain
1. Queue notifyForkchoiceUpdate to engine api.
Notify the execution engine that this block is 'finalized' so that the execution engine can cleanup the non-relevant fork choices.
2. Add post state to stateCache

Send Gossip Topic beacon_block or beacon_block_and_blobs_sidecar#

In https://github.com/ChainSafe/lodestar/blob/v1.5.1/packages/validator/src/services/blockDuties.ts:

clock.runEverySlot(this.runBlockDutiesTask);

where it calls pollBeaconProposersAndNotify to retrieve the beacon proposers and ask them to produce a block. Strictly speaking there will be only one beacon proposer per slot, but since locally we may have multiple heads in fork choice, we may end up with multiple proposers. Hopefully the other nodes are honest, then they will not propagate the message if they know this validator not on duty.

api.validator.getProposerDuties will calculate whether any of the local validators are the next proposer.

If any validator is on duty, it will call createAndPublishBlock. It eventually calls executionEngine.getPayload(endpoint engine_getPayloadV1, engine_getPayloadV2 or engine_getPayloadV3) to send JSON-RPC request to execution client to produce the block.

If MEV is available, then the consensus client will produce 'blinded block'(produceBlindedBlock), which is a block produced without execution client but will get signed 'blindly'. This also links to PBS for 'proposer-builder separation', which is still in research phase. But clients like lodestar seem to already start implementation. The different handling is in https://github.com/ChainSafe/lodestar/blob/v1.5.1/packages/beacon-node/src/api/impl/beacon/blocks/index.ts:

async publishBlindedBlock(signedBlindedBlock) {
  const executionBuilder = chain.executionBuilder;
  if (!executionBuilder) throw Error("exeutionBuilder required to publish SignedBlindedBeaconBlock");
  let signedBlock: allForks.SignedBeaconBlock;

  /*
  ask executionBuilder to create blinded block instead of
  ask executionEngine to produce block
  */
  signedBlock = await executionBuilder.submitBlindedBlock(signedBlindedBlock);

  /* The function below is directly called in case of a non-blinded block */
  return await this.publishBlock(signedBlock);
},

async publishBlock(signedBlock) {
  const seenTimestampSec = Date.now() / 1000;

  // Simple implementation of a pending block queue. Keeping the block here recycles the API logic, and keeps the
  // REST request promise without any extra infrastructure.
  const msToBlockSlot = computeTimeAtSlot(config, signedBlock.message.slot, chain.genesisTime) * 1000 - Date.now();
  if (msToBlockSlot <= MAX_API_CLOCK_DISPARITY_MS && msToBlockSlot > 0) {
    // If block is a bit early, hold it in a promise. Equivalent to a pending queue.
    await sleep(msToBlockSlot);
  }

  // TODO Deneb: Open question if broadcast to both block topic + block_and_blobs topic
  const blockForImport =
    config.getForkSeq(signedBlock.message.slot) >= ForkSeq.deneb
      ? getBlockInput.postDeneb(
          config,
          signedBlock,
          chain.getBlobsSidecar(signedBlock.message as deneb.BeaconBlock)
        )
      : getBlockInput.preDeneb(config, signedBlock);

  await promiseAllMaybeAsync([
    /* sends gossip topic beacon_block or beacon_block_and_blobs_sidecar */
    // Send the block, regardless of whether or not it is valid. The API
    // specification is very clear that this is the desired behaviour.
    () => network.publishBeaconBlockMaybeBlobs(blockForImport),

    /* immediately import the locally proposed block */
    () =>
      chain.processBlock(blockForImport).catch((e) => {
        if (e instanceof BlockError && e.type.code === BlockErrorCode.PARENT_UNKNOWN) {
          network.events.emit(NetworkEvent.unknownBlockParent, blockForImport, network.peerId.toString());
        }
        throw e;
      }),
  ]);
},

Some MEV repos:

Flashbots block builder: https://github.com/flashbots/builder
Another MEC bot: https://github.com/paradigmxyz/artemis