diff --git a/beacon_chain/rpc/rest_beacon_api.nim b/beacon_chain/rpc/rest_beacon_api.nim index 1eed3d11ab..22e34c0ba3 100644 --- a/beacon_chain/rpc/rest_beacon_api.nim +++ b/beacon_chain/rpc/rest_beacon_api.nim @@ -14,6 +14,7 @@ import ../consensus_object_pools/[blockchain_dag, exit_pool, spec_cache], ../spec/[eth2_merkleization, forks, network, validator], ../spec/datatypes/[phase0, altair], + ../validators/message_router_mev, ./state_ttl_cache export rest_utils @@ -795,6 +796,77 @@ proc installBeaconApiHandlers*(router: var RestRouter, node: BeaconNode) = return RestApiResponse.jsonMsgResponse(BlockValidationSuccess) + # https://ethereum.github.io/beacon-APIs/#/Beacon/publishBlindedBlock + # https://github.com/ethereum/beacon-APIs/blob/v2.3.0/apis/beacon/blocks/blinded_blocks.yaml + router.api(MethodPost, "/eth/v1/beacon/blinded_blocks") do ( + contentBody: Option[ContentBody]) -> RestApiResponse: + ## Instructs the beacon node to use the components of the + ## `SignedBlindedBeaconBlock` to construct and publish a + ## `SignedBeaconBlock` by swapping out the transactions_root for the + ## corresponding full list of transactions. The beacon node should + ## broadcast a newly constructed `SignedBeaconBlock` to the beacon network, + ## to be included in the beacon chain. The beacon node is not required to + ## validate the signed `BeaconBlock`, and a successful response (20X) only + ## indicates that the broadcast has been successful. + if contentBody.isNone(): + return RestApiResponse.jsonError(Http400, EmptyRequestBodyError) + + let + currentEpochFork = + node.dag.cfg.stateForkAtEpoch(node.currentSlot().epoch()) + version = request.headers.getString("eth-consensus-version") + body = contentBody.get() + + if body.contentType == OctetStreamMediaType and + currentEpochFork.toString != version: + return RestApiResponse.jsonError(Http400, BlockIncorrectFork) + + case currentEpochFork + of BeaconStateFork.Capella: + return RestApiResponse.jsonError(Http500, $capellaImplementationMissing) + of BeaconStateFork.Bellatrix: + let res = + block: + var restBlock = decodeBodyJsonOrSsz(SignedBlindedBeaconBlock, body).valueOr: + return RestApiResponse.jsonError(Http400, InvalidBlockObjectError, + $error) + await node.unblindAndRouteBlockMEV(restBlock) + + if res.get().isErr(): + return RestApiResponse.jsonError( + Http503, BeaconNodeInSyncError, $res.error()) + if res.get().isNone(): + return RestApiResponse.jsonError(Http202, BlockValidationError) + + return RestApiResponse.jsonMsgResponse(BlockValidationSuccess) + of BeaconStateFork.Altair, BeaconStateFork.Phase0: + # Pre-Bellatrix, this endpoint will accept a `SignedBeaconBlock`. + # + # This is mostly the same as /eth/v1/beacon/blocks for phase 0 and + # altair. + var + restBlock = decodeBody(RestPublishedSignedBeaconBlock, body, + version).valueOr: + return RestApiResponse.jsonError(Http400, InvalidBlockObjectError, + $error) + forked = ForkedSignedBeaconBlock(restBlock) + + if forked.kind != node.dag.cfg.blockForkAtEpoch( + getForkedBlockField(forked, slot).epoch): + return RestApiResponse.jsonError(Http400, InvalidBlockObjectError) + + let res = withBlck(forked): + blck.root = hash_tree_root(blck.message) + await node.router.routeSignedBeaconBlock(blck) + + if res.isErr(): + return RestApiResponse.jsonError( + Http503, BeaconNodeInSyncError, $res.error()) + elif res.get().isNone(): + return RestApiResponse.jsonError(Http202, BlockValidationError) + + return RestApiResponse.jsonMsgResponse(BlockValidationSuccess) + # https://ethereum.github.io/beacon-APIs/#/Beacon/getBlock router.api(MethodGet, "/eth/v1/beacon/blocks/{block_id}") do ( block_id: BlockIdent) -> RestApiResponse: diff --git a/beacon_chain/rpc/rest_constants.nim b/beacon_chain/rpc/rest_constants.nim index 32189e238c..1c4a7e4ccb 100644 --- a/beacon_chain/rpc/rest_constants.nim +++ b/beacon_chain/rpc/rest_constants.nim @@ -233,3 +233,5 @@ const DeprecatedRemovalValidatorBlocksV1* = "v1/validator/blocks/{slot} endpoint was deprecated and replaced by v2, see " & "https://github.com/ethereum/beacon-APIs/pull/220" + BlockIncorrectFork* = + "Block has incorrect fork" diff --git a/beacon_chain/spec/eth2_apis/eth2_rest_serialization.nim b/beacon_chain/spec/eth2_apis/eth2_rest_serialization.nim index 16b1c6ebc3..51a425d73a 100644 --- a/beacon_chain/spec/eth2_apis/eth2_rest_serialization.nim +++ b/beacon_chain/spec/eth2_apis/eth2_rest_serialization.nim @@ -2446,6 +2446,34 @@ proc decodeBody*[T](t: typedesc[T], return err("Unexpected deserialization error") ok(data) +proc decodeBodyJsonOrSsz*[T](t: typedesc[T], + body: ContentBody): Result[T, cstring] = + if body.contentType == ApplicationJsonMediaType: + let data = + try: + RestJson.decode(body.data, T, + requireAllFields = true, + allowUnknownFields = true) + except SerializationError as exc: + debug "Failed to deserialize REST JSON data", + err = exc.formatMsg(""), + data = string.fromBytes(body.data) + return err("Unable to deserialize data") + except CatchableError: + return err("Unexpected deserialization error") + ok(data) + elif body.contentType == OctetStreamMediaType: + let blck = + try: + SSZ.decode(body.data, T) + except SerializationError: + return err("Unable to deserialize data") + except CatchableError: + return err("Unexpected deserialization error") + ok(blck) + else: + return err("Unsupported content type") + proc encodeBytes*[T: EncodeTypes](value: T, contentType: string): RestResult[seq[byte]] = case contentType diff --git a/beacon_chain/validators/message_router_mev.nim b/beacon_chain/validators/message_router_mev.nim new file mode 100644 index 0000000000..9f5d9ddf72 --- /dev/null +++ b/beacon_chain/validators/message_router_mev.nim @@ -0,0 +1,116 @@ +# beacon_chain +# Copyright (c) 2022 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. + +when (NimMajor, NimMinor) < (1, 4): + {.push raises: [Defect].} +else: + {.push raises: [].} + +import std/macros +import metrics +import ../beacon_node + +from eth/async_utils import awaitWithTimeout +from ../spec/datatypes/bellatrix import SignedBeaconBlock +from ../spec/mev/rest_bellatrix_mev_calls import submitBlindedBlock + +const + BUILDER_BLOCK_SUBMISSION_DELAY_TOLERANCE = 4.seconds + +declareCounter beacon_block_builder_proposed, + "Number of beacon chain blocks produced using an external block builder" + +func getFieldNames*(x: typedesc[auto]): seq[string] {.compileTime.} = + var res: seq[string] + for name, _ in fieldPairs(default(x)): + res.add name + res + +macro copyFields*( + dst: untyped, src: untyped, fieldNames: static[seq[string]]): untyped = + result = newStmtList() + for name in fieldNames: + if name notin [ + # These fields are the ones which vary between the blinded and + # unblinded objects, and can't simply be copied. + "transactions_root", "execution_payload", + "execution_payload_header", "body"]: + # TODO use stew/assign2 + result.add newAssignment( + newDotExpr(dst, ident(name)), newDotExpr(src, ident(name))) + +proc unblindAndRouteBlockMEV*( + node: BeaconNode, blindedBlock: SignedBlindedBeaconBlock): + Future[Result[Opt[BlockRef], string]] {.async.} = + # By time submitBlindedBlock is called, must already have done slashing + # protection check + let unblindedPayload = + try: + awaitWithTimeout( + node.payloadBuilderRestClient.submitBlindedBlock(blindedBlock), + BUILDER_BLOCK_SUBMISSION_DELAY_TOLERANCE): + return err("Submitting blinded block timed out") + # From here on, including error paths, disallow local EL production by + # returning Opt.some, regardless of whether on head or newBlock. + except RestDecodingError as exc: + return err("REST decoding error submitting blinded block: " & exc.msg) + except CatchableError as exc: + return err("exception in submitBlindedBlock: " & exc.msg) + + const httpOk = 200 + if unblindedPayload.status == httpOk: + if hash_tree_root( + blindedBlock.message.body.execution_payload_header) != + hash_tree_root(unblindedPayload.data.data): + debug "unblindAndRouteBlockMEV: unblinded payload doesn't match blinded payload", + blindedPayload = + blindedBlock.message.body.execution_payload_header + else: + # Signature provided is consistent with unblinded execution payload, + # so construct full beacon block + # https://github.com/ethereum/builder-specs/blob/v0.2.0/specs/validator.md#block-proposal + var signedBlock = bellatrix.SignedBeaconBlock( + signature: blindedBlock.signature) + copyFields( + signedBlock.message, blindedBlock.message, + getFieldNames(typeof(signedBlock.message))) + copyFields( + signedBlock.message.body, blindedBlock.message.body, + getFieldNames(typeof(signedBlock.message.body))) + signedBlock.message.body.execution_payload = unblindedPayload.data.data + + if signedBlock.root != hash_tree_root(blindedBlock.message): + return err("Unblinded block doesn't match blinded block SSZ root") + + signedBlock.root = hash_tree_root(signedBlock.message) + + debug "unblindAndRouteBlockMEV: proposing unblinded block", + blck = shortLog(signedBlock) + + let newBlockRef = + (await node.router.routeSignedBeaconBlock(signedBlock)).valueOr: + # submitBlindedBlock has run, so don't allow fallback to run + return err("routeSignedBeaconBlock error") # Errors logged in router + + if newBlockRef.isSome: + beacon_block_builder_proposed.inc() + notice "Block proposed (MEV)", + blockRoot = shortLog(signedBlock.root), blck = shortLog(signedBlock), + signature = shortLog(signedBlock.signature) + + return ok newBlockRef + else: + debug "unblindAndRouteBlockMEV: submitBlindedBlock failed", + blindedBlock, payloadStatus = unblindedPayload.status + + # https://github.com/ethereum/builder-specs/blob/v0.2.0/specs/validator.md#proposer-slashing + # This means if a validator publishes a signature for a + # `BlindedBeaconBlock` (via a dissemination of a + # `SignedBlindedBeaconBlock`) then the validator **MUST** not use the + # local build process as a fallback, even in the event of some failure + # with the external buildernetwork. + return err("unblindAndRouteBlockMEV error") diff --git a/beacon_chain/validators/validator_duties.nim b/beacon_chain/validators/validator_duties.nim index 7506b7d511..6c6bc739b8 100644 --- a/beacon_chain/validators/validator_duties.nim +++ b/beacon_chain/validators/validator_duties.nim @@ -49,7 +49,6 @@ const delayBuckets = [-Inf, -4.0, -2.0, -1.0, -0.5, -0.1, -0.05, 0.05, 0.1, 0.5, 1.0, 2.0, 4.0, 8.0, Inf] - BUILDER_BLOCK_SUBMISSION_DELAY_TOLERANCE = 4.seconds BUILDER_STATUS_DELAY_TOLERANCE = 3.seconds BUILDER_VALIDATOR_REGISTRATION_DELAY_TOLERANCE = 3.seconds @@ -70,9 +69,6 @@ declareCounter beacon_block_payload_errors, "Number of times execution client failed to produce block payload" # Metrics for tracking external block builder usage -declareCounter beacon_block_builder_proposed, - "Number of beacon chain blocks produced using an external block builder" - declareCounter beacon_block_builder_missed_with_fallback, "Number of beacon chain blocks where an attempt to use an external block builder failed with fallback" @@ -549,26 +545,8 @@ proc getBlindedExecutionPayload( return ok blindedHeader.data.data.message.header -import std/macros - -func getFieldNames(x: typedesc[auto]): seq[string] {.compileTime.} = - var res: seq[string] - for name, _ in fieldPairs(default(x)): - res.add name - res - -macro copyFields( - dst: untyped, src: untyped, fieldNames: static[seq[string]]): untyped = - result = newStmtList() - for name in fieldNames: - if name notin [ - # These fields are the ones which vary between the blinded and - # unblinded objects, and can't simply be copied. - "transactions_root", "execution_payload", - "execution_payload_header", "body"]: - # TODO use stew/assign2 - result.add newAssignment( - newDotExpr(dst, ident(name)), newDotExpr(src, ident(name))) +from ./message_router_mev import + copyFields, getFieldNames, unblindAndRouteBlockMEV func constructSignableBlindedBlock[T]( forkedBlock: ForkedBeaconBlock, @@ -725,93 +703,34 @@ proc proposeBlockMEV( 500.milliseconds): Result[SignedBlindedBeaconBlock, string].err "getBlindedBlock timed out" - if blindedBlock.isOk: - # By time submitBlindedBlock is called, must already have done slashing - # protection check - let unblindedPayload = - try: - awaitWithTimeout( - node.payloadBuilderRestClient.submitBlindedBlock(blindedBlock.get), - BUILDER_BLOCK_SUBMISSION_DELAY_TOLERANCE): - error "Submitting blinded block timed out", - blk = shortLog(blindedBlock.get) - return Opt.some head - # From here on, including error paths, disallow local EL production by - # returning Opt.some, regardless of whether on head or newBlock. - except RestDecodingError as exc: - error "proposeBlockMEV: REST decoding error submitting blinded block", - slot, head = shortLog(head), validator_index, blindedBlock, - error = exc.msg - return Opt.some head - except CatchableError as exc: - error "proposeBlockMEV: exception in submitBlindedBlock", - slot, head = shortLog(head), validator_index, blindedBlock, - error = exc.msg - return Opt.some head - - const httpOk = 200 - if unblindedPayload.status == httpOk: - if hash_tree_root( - blindedBlock.get.message.body.execution_payload_header) != - hash_tree_root(unblindedPayload.data.data): - debug "proposeBlockMEV: unblinded payload doesn't match blinded payload", - blindedPayload = - blindedBlock.get.message.body.execution_payload_header - else: - # Signature provided is consistent with unblinded execution payload, - # so construct full beacon block - # https://github.com/ethereum/builder-specs/blob/v0.2.0/specs/validator.md#block-proposal - var signedBlock = bellatrix.SignedBeaconBlock( - signature: blindedBlock.get.signature) - copyFields( - signedBlock.message, blindedBlock.get.message, - getFieldNames(typeof(signedBlock.message))) - copyFields( - signedBlock.message.body, blindedBlock.get.message.body, - getFieldNames(typeof(signedBlock.message.body))) - signedBlock.message.body.execution_payload = unblindedPayload.data.data - - signedBlock.root = hash_tree_root(signedBlock.message) - - doAssert signedBlock.root == hash_tree_root(blindedBlock.get.message) - - debug "proposeBlockMEV: proposing unblinded block", - blck = shortLog(signedBlock) - - let newBlockRef = - (await node.router.routeSignedBeaconBlock(signedBlock)).valueOr: - # submitBlindedBlock has run, so don't allow fallback to run - return Opt.some head # Errors logged in router - - if newBlockRef.isNone(): - return Opt.some head # Validation errors logged in router - - beacon_block_builder_proposed.inc() - notice "Block proposed (MEV)", - blockRoot = shortLog(signedBlock.root), blck = shortLog(signedBlock), - signature = shortLog(signedBlock.signature), validator = shortLog(validator) - - beacon_blocks_proposed.inc() - - return Opt.some newBlockRef.get() - else: - debug "proposeBlockMEV: submitBlindedBlock failed", - slot, head = shortLog(head), validator_index, blindedBlock, - payloadStatus = unblindedPayload.status - - # https://github.com/ethereum/builder-specs/blob/v0.2.0/specs/validator.md#proposer-slashing - # This means if a validator publishes a signature for a - # `BlindedBeaconBlock` (via a dissemination of a - # `SignedBlindedBeaconBlock`) then the validator **MUST** not use the - # local build process as a fallback, even in the event of some failure - # with the external buildernetwork. - return Opt.some head - else: + if blindedBlock.isErr: info "proposeBlockMEV: getBlindedBeaconBlock failed", slot, head = shortLog(head), validator_index, blindedBlock, error = blindedBlock.error return Opt.none BlockRef + # Before unblindAndRouteBlockMEV, can fall back to EL; after, cannot + let unblindedBlockRef = await node.unblindAndRouteBlockMEV( + blindedBlock.get) + return if unblindedBlockRef.isOk and unblindedBlockRef.get.isSome: + beacon_blocks_proposed.inc() + unblindedBlockRef.get + else: + # Signal to the caller that a signed, blinded beacon block was sent to the + # builder API server, at which point no local EL fallback can occur. Using + # non-`none` opt with the same head indicates this to proposeBlock(), with + # any non-`none` return value indicating this in general. + # + # unblindedBlockRef.isOk and unblindedBlockRef.get.isNone indicates that + # the block failed to validate and integrate into the DAG, which for the + # purpose of this return value, is equivalent. It's used to drive Beacon + # REST API output. + warn "proposeBlockMEV: blinded block not successfully unblinded and proposed", + head = shortLog(head), slot, validator_index, + validator = shortLog(validator), + err = unblindedBlockRef.error, blindedBlck = shortLog(blindedBlock.get) + Opt.some head + proc makeBlindedBeaconBlockForHeadAndSlot*( node: BeaconNode, randao_reveal: ValidatorSig, validator_index: ValidatorIndex, graffiti: GraffitiBytes, head: BlockRef, diff --git a/ncli/resttest-rules.json b/ncli/resttest-rules.json index e2540f5578..3c799ce4aa 100644 --- a/ncli/resttest-rules.json +++ b/ncli/resttest-rules.json @@ -2307,6 +2307,20 @@ }, "response": {"status": {"operator": "equals", "value": "400"}} }, + { + "topics": ["beacon", "beacon_block_blinded_blocks"], + "request": { + "url": "/eth/v1/beacon/blinded_blocks", + "method": "POST", + "headers": {"Accept": "application/json"}, + "body": {"content-type": "application/json", "data": "[]"} + }, + "response": { + "status": {"operator": "equals", "value": "400"}, + "headers": [{"key": "Content-Type", "value": "application/json", "operator": "equals"}], + "body": [{"operator": "jstructcmpns", "value": {"code": 400, "message": ""}}] + } + }, { "topics": ["beacon", "beacon_light_client_bootstrap_blockroot"], "request": {