diff --git a/AllTests-mainnet.md b/AllTests-mainnet.md index 7f1adbc7e3..ccda0e3891 100644 --- a/AllTests-mainnet.md +++ b/AllTests-mainnet.md @@ -589,6 +589,13 @@ AllTests-mainnet + Roundtrip engine RPC V2 and capella ExecutionPayload representations OK + Roundtrip engine RPC V3 and deneb ExecutionPayload representations OK ``` +## Envelope Quarantine +```diff ++ Add missing OK ++ Add orphan OK ++ Clean up orphans OK ++ Pop orphan OK +``` ## Eth1 monitor ```diff + Rewrite URLs OK diff --git a/beacon_chain/beacon_chain_db.nim b/beacon_chain/beacon_chain_db.nim index f4485c0790..17e95ed6d9 100644 --- a/beacon_chain/beacon_chain_db.nim +++ b/beacon_chain/beacon_chain_db.nim @@ -121,6 +121,8 @@ type stateRoots: KvStoreRef # (Slot, BlockRoot) -> StateRoot + envelopes: KvStoreRef # (BlockRoot -> SignedExecutionPayloadEnvelope) + statesNoVal: array[ConsensusFork, KvStoreRef] # StateRoot -> ForkBeaconStateNoImmutableValidators stateDiffs: KvStoreRef ##\ @@ -604,6 +606,10 @@ proc new*(T: type BeaconChainDB, if cfg.FULU_FORK_EPOCH != FAR_FUTURE_EPOCH: columns = kvStore db.openKvStore("fulu_columns").expectDb() + var envelopes: KvStoreRef + if cfg.GLOAS_FORK_EPOCH != FAR_FUTURE_EPOCH: + envelopes = kvStore db.openKvStore("gloas_envelopes").expectDb() + let quarantine = db.initQuarantineDB().expectDb() # Versions prior to 1.4.0 (altair) stored validators in `immutable_validators` @@ -642,6 +648,7 @@ proc new*(T: type BeaconChainDB, blocks: blocks, blobs: blobs, columns: columns, + envelopes: envelopes, stateRoots: stateRoots, statesNoVal: statesNoVal, stateDiffs: stateDiffs, @@ -866,6 +873,14 @@ proc delDataColumnSidecar*( root: Eth2Digest, index: ColumnIndex): bool = db.columns.del(columnkey(root, index)).expectDb() +proc putExecutionPayloadEnvelope*( + db: BeaconChainDB, value: SignedExecutionPayloadEnvelope) = + template key: untyped = value.message.beacon_block_root + db.envelopes.putSZSSZ(key.data, value) + +proc delExecutionPayloadEnvelope*(db: BeaconChainDB, root: Eth2Digest): bool = + db.envelopes.del(root.data).expectDb() + proc updateImmutableValidators*( db: BeaconChainDB, validators: openArray[Validator]) = # Must be called before storing a state that references the new validators @@ -1078,6 +1093,13 @@ proc getDataColumnSidecar*(db: BeaconChainDB, root: Eth2Digest, index: ColumnInd return false db.columns.getSZSSZ(columnkey(root, index), value) == GetResult.found +proc getExecutionPayloadEnvelope*( + db: BeaconChainDB, root: Eth2Digest, + value: var TrustedSignedExecutionPayloadEnvelope): bool = + if db.envelopes == nil: + return false + db.envelopes.getSZSSZ(root.data, value) == GetResult.found + proc getBlockSZ*[X: ForkyTrustedSignedBeaconBlock]( db: BeaconChainDB, key: Eth2Digest, data: var seq[byte], T: typedesc[X]): bool = diff --git a/beacon_chain/consensus_object_pools/envelope_quarantine.nim b/beacon_chain/consensus_object_pools/envelope_quarantine.nim new file mode 100644 index 0000000000..d34287d50b --- /dev/null +++ b/beacon_chain/consensus_object_pools/envelope_quarantine.nim @@ -0,0 +1,76 @@ +# beacon_chain +# Copyright (c) 2025 Status Research & Development GmbH +# Licensed and distributed under either of +# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT). +# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0). +# at your option. This file may not be copied, modified, or distributed except according to those terms. + +{.push raises: [], gcsafe.} + +import std/tables +import ../spec/[digest, forks] + +type + EnvelopeQuarantine* = object + orphans*: Table[Eth2Digest, Table[uint64, SignedExecutionPayloadEnvelope]] + ## Envelopes that we have received but did not have a block yet. In the + ## ideal scenario, block should arrive before envelope but that is not + ## guaranteed. + + missing*: HashSet[Eth2Digest] + ## List of block roots that we would like to have the envelopes but we + ## have not got yet. Missing envelopes should usually be found when we + ## received a block, blob or data column. + +func init*(T: typedesc[EnvelopeQuarantine]): T = + T() + +template root(v: SignedExecutionPayloadEnvelope): Eth2Digest = + v.message.beacon_block_root + +func addMissing*( + self: var EnvelopeQuarantine, + root: Eth2Digest) = + self.missing.incl(root) + +func addOrphan*( + self: var EnvelopeQuarantine, + envelope: SignedExecutionPayloadEnvelope) = + discard self.orphans + .mgetOrPut(envelope.root) + .hasKeyOrPut(envelope.message.builder_index, envelope) + +func popOrphan*( + self: var EnvelopeQuarantine, + blck: gloas.SignedBeaconBlock, +): Opt[SignedExecutionPayloadEnvelope] = + if blck.root notin self.orphans: + return Opt.none(SignedExecutionPayloadEnvelope) + + template builderIdx: untyped = + blck.message.body.signed_execution_payload_bid.message.builder_index + try: + var envelope: SignedExecutionPayloadEnvelope + if self.orphans[blck.root].pop(builderIdx, envelope): + Opt.some(envelope) + else: + Opt.none(SignedExecutionPayloadEnvelope) + except KeyError: + Opt.none(SignedExecutionPayloadEnvelope) + finally: + # After poping an envelope by block, the rest will no longer be valid due to + # the mismatch builder index. + self.orphans.del(blck.root) + +func cleanupOrphans*(self: var EnvelopeQuarantine, finalizedSlot: Slot) = + var toDel: seq[Eth2Digest] + + for k, v in self.orphans: + for _, e in v: + if finalizedSlot >= e.message.slot: + toDel.add(k) + # check only the first envelope as slot should be the same by block root. + break + + for k in toDel: + self.orphans.del(k) diff --git a/beacon_chain/gossip_processing/eth2_processor.nim b/beacon_chain/gossip_processing/eth2_processor.nim index d058549117..fde86d3a4b 100644 --- a/beacon_chain/gossip_processing/eth2_processor.nim +++ b/beacon_chain/gossip_processing/eth2_processor.nim @@ -16,9 +16,9 @@ import ../el/el_manager, ../spec/[helpers, forks], ../consensus_object_pools/[ - blob_quarantine, block_clearance, block_quarantine, blockchain_dag, - attestation_pool, execution_payload_pool, light_client_pool, - sync_committee_msg_pool, validator_change_pool], + attestation_pool, blob_quarantine, block_clearance, block_quarantine, + blockchain_dag, envelope_quarantine, execution_payload_pool, + light_client_pool, sync_committee_msg_pool, validator_change_pool], ../validators/validator_pool, ../beacon_clock, "."/[gossip_validation, block_processor, batch_validation], @@ -45,6 +45,10 @@ declareCounter beacon_blocks_received, "Number of valid blocks processed by this node" declareCounter beacon_blocks_dropped, "Number of invalid blocks dropped by this node", labels = ["reason"] +declareCounter execution_payload_envelopes_received, + "Number of valid execution payload envelope processed by this node" +declareCounter execution_payload_envelopes_dropped, + "Number of invalid execution payload envelope dropped by this node", labels = ["reason"] declareCounter blob_sidecars_received, "Number of valid blobs processed by this node" declareCounter blob_sidecars_dropped, @@ -86,7 +90,7 @@ declareCounter beacon_execution_payload_bids_received, "Number of valid execution payload bids processed by this node" declareCounter beacon_execution_payload_bids_dropped, - "Number of invalid execution payload bids dropped by this node", + "Number of invalid execution payload bids dropped by this node", labels = ["reason"] const delayBuckets = [2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, Inf] @@ -100,6 +104,9 @@ declareHistogram beacon_aggregate_delay, declareHistogram beacon_block_delay, "Time(s) between slot start and beacon block reception", buckets = delayBuckets +declareHistogram execution_payload_envelope_delay, + "Time(s) between slot start and execution payload envelope reception", buckets = delayBuckets + declareHistogram blob_sidecar_delay, "Time(s) between slot start and blob sidecar reception", buckets = delayBuckets @@ -165,6 +172,8 @@ type dataColumnQuarantine*: ref ColumnQuarantine + envelopeQuarantine*: ref EnvelopeQuarantine + # Application-provided current time provider (to facilitate testing) getCurrentBeaconTime*: GetBeaconTimeFn @@ -305,6 +314,37 @@ proc processSignedBeaconBlock*( ok() +proc processExecutionPayloadEnvelope*( + self: var Eth2Processor, src: MsgSource, + signedEnvelope: SignedExecutionPayloadEnvelope): ValidationRes = + let + wallTime = self.getCurrentBeaconTime() + (afterGenesis, wallSlot) = wallTime.toSlot(self.dag.timeParams) + + logScope: + blockRoot = shortLog(signedEnvelope.message.beacon_block_root) + envelope = shortLog(signedEnvelope.message) + wallSlot + + if not afterGenesis: + notice "Execution payload envelope before genesis" + return errIgnore("Execution payload envelope before genesis") + + let delay = wallTime - + signedEnvelope.message.slot.start_beacon_time(self.dag.timeParams) + + self.dag.validateExecutionPayload( + self.quarantine, self.envelopeQuarantine, signedEnvelope).isOkOr: + execution_payload_envelopes_dropped.inc(1, [$error[0]]) + return err(error) + + debugGloasComment("process execution payload") + + execution_payload_envelopes_received.inc() + execution_payload_envelope_delay.observe(delay.toFloatSeconds()) + + ok() + proc processBlobSidecar*( self: var Eth2Processor, src: MsgSource, blobSidecar: deneb.BlobSidecar, subnet_id: BlobId): ValidationRes = diff --git a/beacon_chain/gossip_processing/gossip_validation.nim b/beacon_chain/gossip_processing/gossip_validation.nim index 28e78e365f..a51f42b671 100644 --- a/beacon_chain/gossip_processing/gossip_validation.nim +++ b/beacon_chain/gossip_processing/gossip_validation.nim @@ -18,8 +18,9 @@ import helpers, network, signatures, peerdas_helpers], ../consensus_object_pools/[ attestation_pool, blockchain_dag, blob_quarantine, block_clearance, - block_quarantine, execution_payload_pool, spec_cache, light_client_pool, - sync_committee_msg_pool, validator_change_pool], + block_quarantine, envelope_quarantine, execution_payload_pool, + light_client_pool, spec_cache, sync_committee_msg_pool, + validator_change_pool], ".."/[beacon_clock], ./batch_validation @@ -952,6 +953,95 @@ proc validateBeaconBlock*( ok() +# https://github.com/ethereum/consensus-specs/blob/v1.6.0/specs/gloas/p2p-interface.md#execution_payload +proc validateExecutionPayload*( + dag: ChainDAGRef, quarantine: ref Quarantine, + envelopeQuarantine: ref EnvelopeQuarantine, + signed_execution_payload_envelope: SignedExecutionPayloadEnvelope): + Result[void, ValidationError] = + template envelope: untyped = signed_execution_payload_envelope.message + + # [IGNORE] The envelope's block root envelope.block_root has been seen (via + # gossip or non-gossip sources) (a client MAY queue payload for processing + # once the block is retrieved). + let blockSeen = + block: + var seen = + envelope.beacon_block_root in quarantine.unviable or + envelope.beacon_block_root in quarantine.missing or + dag.getBlockRef(envelope.beacon_block_root).isSome() + if not seen: + for k, _ in quarantine.orphans: + if k[0] == envelope.beacon_block_root: + seen = true + break + seen + if not blockSeen: + quarantine[].addMissing(envelope.beacon_block_root) + envelopeQuarantine[].addOrphan(signed_execution_payload_envelope) + return errIgnore("ExecutionPayload: block not found") + + # [IGNORE] The node has not seen another valid SignedExecutionPayloadEnvelope + # for this block root from this builder. + # + # Validation of an envelope requires a valid block. There is a check to ensure + # that the builder index are the same from the envelope and the bid from the + # block. Meaning that checking builder index here would not be helpful due to + # the check later. + var validEnvelope: TrustedSignedExecutionPayloadEnvelope + if dag.db.getExecutionPayloadEnvelope( + envelope.beacon_block_root, validEnvelope): + return errIgnore("ExecutionPayload: already seen") + + # [IGNORE] The envelope is from a slot greater than or equal to the latest + # finalized slot -- i.e. validate that `envelope.slot >= + # compute_start_slot_at_epoch(store.finalized_checkpoint.epoch)` + if envelope.slot <= dag.finalizedHead.slot: + return errIgnore("ExecutionPayload: slot already finalized") + + # [REJECT] block passes validation. + let blck = + block: + let forkedBlock = dag.getForkedBlock(BlockId( + root: envelope.beacon_block_root, slot: envelope.slot)).valueOr: + return dag.checkedReject("ExecutionPayload: invalid block") + withBlck(forkedBlock): + when consensusFork >= ConsensusFork.Gloas: + forkyBlck.asSigned().message + else: + return dag.checkedReject("ExecutionPayload: invalid fork") + + # [REJECT] block.slot equals envelope.slot. + if blck.slot != envelope.slot: + return dag.checkedReject("ExecutionPayload: slot mismatch") + + template bid: untyped = blck.body.signed_execution_payload_bid.message + + # [REJECT] envelope.builder_index == bid.builder_index + if envelope.builder_index != bid.builder_index: + return dag.checkedReject("ExecutionPayload: builder index mismatch") + + # [REJECT] payload.block_hash == bid.block_hash + if envelope.payload.block_hash != bid.block_hash: + return dag.checkedReject("ExecutionPayload: block hash mismatch") + + # [REJECT] signed_execution_payload_envelope.signature is valid with respect + # to the builder's public key. + withState(dag.headState): + when consensusFork >= ConsensusFork.Gloas: + if not verify_execution_payload_envelope_signature( + dag.forkAtEpoch(envelope.slot.epoch), + getStateField(dag.headState, genesis_validators_root), + envelope.slot.epoch, + signed_execution_payload_envelope.message, + dag.validatorKey(envelope.builder_index).get(), + signed_execution_payload_envelope.signature): + return dag.checkedReject("ExecutionPayload: invalid builder signature") + else: + return dag.checkedReject("ExecutionPayload: invalid fork") + + ok() + # https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.1/specs/phase0/p2p-interface.md#beacon_attestation_subnet_id # https://github.com/ethereum/consensus-specs/blob/v1.4.0-beta.5/specs/deneb/p2p-interface.md#beacon_aggregate_and_proof proc validateAttestation*( diff --git a/beacon_chain/spec/datatypes/gloas.nim b/beacon_chain/spec/datatypes/gloas.nim index bad86ef6a0..5cfd72bc56 100644 --- a/beacon_chain/spec/datatypes/gloas.nim +++ b/beacon_chain/spec/datatypes/gloas.nim @@ -102,11 +102,24 @@ type blob_kzg_commitments*: KzgCommitments state_root*: Eth2Digest + TrustedExecutionPayloadEnvelope* = object + payload*: deneb.ExecutionPayload + execution_requests*: ExecutionRequests + builder_index*: uint64 + beacon_block_root*: Eth2Digest + slot*: Slot + blob_kzg_commitments*: KzgCommitments + state_root*: Eth2Digest + # https://github.com/ethereum/consensus-specs/blob/v1.6.0-alpha.6/specs/gloas/beacon-chain.md#signedexecutionpayloadenvelope SignedExecutionPayloadEnvelope* = object message*: ExecutionPayloadEnvelope signature*: ValidatorSig + TrustedSignedExecutionPayloadEnvelope* = object + message*: TrustedExecutionPayloadEnvelope + signature*: ValidatorSig + # https://github.com/ethereum/consensus-specs/blob/v1.6.0-alpha.6/specs/gloas/beacon-chain.md#payloadattestationdata PayloadAttestationData* = object beacon_block_root*: Eth2Digest @@ -616,11 +629,23 @@ func shortLog*(v: ExecutionPayloadBid): auto = blob_kzg_commitments_root: shortLog(v.blob_kzg_commitments_root), ) +func shortLog*(v: ExecutionPayloadEnvelope): auto = + ( + beacon_block_root: shortLog(v.beacon_block_root), + slot: v.slot, + builder_index: v.builder_index, + state_root: shortLog(v.state_root) + ) + template asSigned*( x: SigVerifiedSignedBeaconBlock | TrustedSignedBeaconBlock): SignedBeaconBlock = isomorphicCast[SignedBeaconBlock](x) +template asSigned*( + x: TrustedSignedExecutionPayloadEnvelope): SignedExecutionPayloadEnvelope = + isomorphicCast[SignedExecutionPayloadEnvelope](x) + template asSigVerified*( x: SignedBeaconBlock | TrustedSignedBeaconBlock): SigVerifiedSignedBeaconBlock = diff --git a/tests/all_tests.nim b/tests/all_tests.nim index c3595e21be..8d3c4faed9 100644 --- a/tests/all_tests.nim +++ b/tests/all_tests.nim @@ -26,6 +26,7 @@ import # Unit test ./test_discovery, ./test_engine_api_conversions, ./test_engine_authentication, + ./test_envelope_quarantine, ./test_el_manager, ./test_el_conf, ./test_eth2_rest_serialization, diff --git a/tests/test_envelope_quarantine.nim b/tests/test_envelope_quarantine.nim new file mode 100644 index 0000000000..e81d03ba21 --- /dev/null +++ b/tests/test_envelope_quarantine.nim @@ -0,0 +1,87 @@ +# beacon_chain +# Copyright (c) 2025 Status Research & Development GmbH +# Licensed and distributed under either of +# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT). +# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0). +# at your option. This file may not be copied, modified, or distributed except according to those terms. + +{.push raises: [].} +{.used.} + +import + unittest2, + ../beacon_chain/consensus_object_pools/envelope_quarantine, + ../beacon_chain/spec/forks + +from stew/byteutils import hexToByteArray + +suite "Envelope Quarantine": + setup: + var quarantine = EnvelopeQuarantine.init() + # Block root for testing + let root1 = Eth2Digest(data:hexToByteArray[32]( + "6aaaaaaaaa5aaaaaaaaa4aaaaaaaaa3aaaaaaaaa2aaaaaaaaa1aaaaaaaaa0001" + .toOpenArray(0, 63))) + + test "Add missing": + check root1 notin quarantine.missing + quarantine.addMissing(root1) + check root1 in quarantine.missing + + test "Add orphan": + check root1 notin quarantine.orphans + quarantine.addOrphan(SignedExecutionPayloadEnvelope( + message: ExecutionPayloadEnvelope( + beacon_block_root: root1, + builder_index: 1'u64))) + check root1 in quarantine.orphans + check 1'u64 in quarantine.orphans[root1] + + test "Pop orphan": + let + envelope = SignedExecutionPayloadEnvelope( + message: ExecutionPayloadEnvelope( + beacon_block_root: root1, + builder_index: 1'u64)) + blckBid = gloas.SignedExecutionPayloadBid( + message: gloas.ExecutionPayloadBid( + builder_index: 1'u64)) + blck = gloas.BeaconBlock( + body: gloas.BeaconBlockBody( + signed_execution_payload_bid: blckBid)) + signedBlck = gloas.SignedBeaconBlock(root: root1, message: blck) + + quarantine.addOrphan(envelope) + check quarantine.popOrphan(signedBlck) == Opt.some(envelope) + check root1 notin quarantine.orphans + + quarantine.addOrphan(envelope) + check quarantine.popOrphan(gloas.SignedBeaconBlock(root: root1)) == + Opt.none(SignedExecutionPayloadEnvelope) + check root1 notin quarantine.orphans + + quarantine.addOrphan(envelope) + check quarantine.popOrphan(gloas.SignedBeaconBlock(message: blck)) == + Opt.none(SignedExecutionPayloadEnvelope) + check root1 in quarantine.orphans + + test "Clean up orphans": + let + root2 = Eth2Digest(data:hexToByteArray[32]( + "6aaaaaaaaa5aaaaaaaaa4aaaaaaaaa3aaaaaaaaa2aaaaaaaaa1aaaaaaaaa0002" + .toOpenArray(0, 63))) + root3 = Eth2Digest(data:hexToByteArray[32]( + "6aaaaaaaaa5aaaaaaaaa4aaaaaaaaa3aaaaaaaaa2aaaaaaaaa1aaaaaaaaa0003" + .toOpenArray(0, 63))) + + quarantine.addOrphan(SignedExecutionPayloadEnvelope( + message: ExecutionPayloadEnvelope(beacon_block_root: root1, slot: 3.Slot))) + quarantine.addOrphan(SignedExecutionPayloadEnvelope( + message: ExecutionPayloadEnvelope(beacon_block_root: root2, slot: 5.Slot))) + quarantine.addOrphan(SignedExecutionPayloadEnvelope( + message: ExecutionPayloadEnvelope(beacon_block_root: root3, slot: 7.Slot))) + + quarantine.cleanupOrphans(3.Slot) + check quarantine.orphans.len == 2 + quarantine.cleanupOrphans(8.Slot) + check quarantine.orphans.len == 0