diff --git a/Makefile b/Makefile index 3311e1960..649384676 100644 --- a/Makefile +++ b/Makefile @@ -58,6 +58,7 @@ TOOLS_CORE := \ deposit_contract \ resttest \ logtrace \ + mev_mock \ ncli \ ncli_db \ ncli_split_keystore \ diff --git a/research/mev_mock.nim b/research/mev_mock.nim new file mode 100644 index 000000000..9e3d0e99b --- /dev/null +++ b/research/mev_mock.nim @@ -0,0 +1,199 @@ +# beacon_chain +# Copyright (c) 2023 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. + +import + confutils, + ../beacon_chain/spec/datatypes/capella, + ../beacon_chain/rpc/rest_utils, + ../beacon_chain/spec/eth2_apis/rest_beacon_client + +const HttpOk = 200 + +type + ParentHeaderInfo = object + block_number: uint64 + timestamp: uint64 + + MevMockConf* = object + # Deliberately no default. Assuming such has caused too many CI issues + port {. desc: "REST HTTP server port" .}: int + +proc getPrevRandao(restClient: RestClientRef): + Future[Opt[Eth2Digest]] {.async.} = + let resp: RestResponse[rest_types.GetStateRandaoResponse] = + await restClient.getStateRandao(StateIdent.init(StateIdentType.Head)) + + return if resp.status == HttpOk: + Opt.some resp.data.data.randao + else: + Opt.none Eth2Digest + +proc getParentBlock(restClient: RestClientRef): + Future[Opt[ParentHeaderInfo]] {.async.} = + let + respMaybe: Option[ref ForkedSignedBeaconBlock] = + # defaultRuntimeConfig only kicks in for SSZ and this can use JSON + await restClient.getBlockV2( + BlockIdent.init(BlockIdentType.Head), defaultRuntimeConfig) + resp = + if respMaybe.isSome and not respMaybe.get.isNil: + respMaybe.get[] + else: + return Opt.none ParentHeaderInfo + + withBlck(resp): + when consensusFork >= ConsensusFork.Capella: + return Opt.some ParentHeaderInfo( + block_number: blck.message.body.execution_payload.block_number, + timestamp: blck.message.body.execution_payload.timestamp) + else: + discard + +proc getWithdrawals(restClient: RestClientRef): + Future[Opt[seq[Withdrawal]]] {.async.} = + let resp: RestResponse[rest_types.GetNextWithdrawalsResponse] = + await restClient.getNextWithdrawals(StateIdent.init(StateIdentType.Head)) + + return if resp.status == HttpOk: + Opt.some resp.data.data + else: + Opt.none seq[Withdrawal] + +proc getInfo(parent_hash: Eth2Digest): + Future[Opt[capella.ExecutionPayload]] {.async.} = + const DEFAULT_GAS_LIMIT: uint64 = 30000000 + + # TODO parallelize with await allFutures() to at least mitigate head race + var restClient: RestClientRef + let + prev_randao = (await getPrevRandao(restClient)).valueOr: + return Opt.none capella.ExecutionPayload + parent_block_info = (await getParentBlock(restClient)).valueOr: + return Opt.none capella.ExecutionPayload + withdrawals = (await getWithdrawals(restClient)).valueOr: + return Opt.none capella.ExecutionPayload + + var execution_payload = capella.ExecutionPayload( + parent_hash: parent_hash, + fee_recipient: default(ExecutionAddress), # only a CL suggestion + logs_bloom: default(BloomLogs), + timestamp: parentBlockInfo.timestamp, + prev_randao: prev_randao, + block_number: parent_block_info.block_number, + gas_limit: DEFAULT_GAS_LIMIT, + gas_used: 0, + extra_data: default(List[byte, 32]), + transactions: default(List[Transaction, 1048576]), + withdrawals: List[capella.Withdrawal, 16].init(withdrawals) + ) + + return Opt.some execution_payload + +func getExecutionPayloadHeader(execution_payload: capella.ExecutionPayload): + capella.ExecutionPayloadHeader = + capella.ExecutionPayloadHeader( + parent_hash: execution_payload.parent_hash, + fee_recipient: execution_payload.fee_recipient, + state_root: execution_payload.state_root, + receipts_root: execution_payload.receipts_root, + logs_bloom: execution_payload.logs_bloom, + prev_randao: execution_payload.prev_randao, + block_number: execution_payload.block_number, + gas_limit: execution_payload.gas_limit, + gas_used: execution_payload.gas_used, + timestamp: execution_payload.timestamp, + base_fee_per_gas: execution_payload.base_fee_per_gas, + block_hash: execution_payload.block_hash, + extra_data: execution_payload.extra_data, + transactions_root: hash_tree_root(execution_payload.transactions), + withdrawals_root: hash_tree_root(execution_payload.withdrawals)) + +func getSignedUnblindedBeaconBlock( + signedBlindedBlck: capella_mev.SignedBlindedBeaconBlock, + execution_payload: capella.ExecutionPayload): capella.SignedBeaconBlock = + template blindedBlck: untyped = signedBlindedBlck.message + var blck = capella.SignedBeaconBlock( + message: capella.BeaconBlock( + slot: blindedBlck.slot, + parent_root: blindedBlck.parent_root, + state_root: blindedBlck.state_root, + body: capella.BeaconBlockBody( + randao_reveal: blindedBlck.body.randao_reveal, + eth1_data: blindedBlck.body.eth1_data, + graffiti: blindedBlck.body.graffiti, + proposer_slashings: blindedBlck.body.proposer_slashings, + attester_slashings: blindedBlck.body.attester_slashings, + deposits: blindedBlck.body.deposits, + voluntary_exits: blindedBlck.body.voluntary_exits, + sync_aggregate: blindedBlck.body.sync_aggregate, + execution_payload: execution_payload, + bls_to_execution_changes: + blindedBlck.body.bls_to_execution_changes)), + signature: signedBlindedBlck.signature) + blck.root = hash_tree_root(blck.message) + blck + +proc setupEngineAPI*(router: var RestRouter, payloadCache: + TableRef[Eth2Digest, capella.ExecutionPayload]) = + router.api(MethodPost, "/eth/v1/builder/validators") do ( + contentBody: Option[ContentBody]) -> RestApiResponse: + + if contentBody.isNone: + return RestApiResponse.jsonError(Http400, EmptyRequestBodyError) + + # No-op, deliberately. For this purpse, the only thing this does + return RestApiResponse.jsonResponse("") + + router.api(MethodGet, "/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey}") do ( + slot: Slot, parent_hash: Eth2Digest, pubkey: ValidatorPubKey) -> RestApiResponse: + if parent_hash.isErr: + return RestApiResponse.jsonError(Http400, "No parent head hash provided") + let execution_payload = (await getInfo(parent_hash.get)).valueOr: + return RestApiResponse.jsonError(Http400, "Error getting parent head information") + payloadCache[hash_tree_root(execution_payload)] = execution_payload + return RestApiResponse.jsonResponse( + getExecutionPayloadHeader(execution_payload)) + + router.api(MethodPost, "/eth/v1/builder/blinded_blocks") do ( + contentBody: Option[ContentBody]) -> RestApiResponse: + if contentBody.isNone: + return RestApiResponse.jsonError(Http400, EmptyRequestBodyError) + + let + body = contentBody.get() + restBlock = decodeBody( + capella_mev.SignedBlindedBeaconBlock, body).valueOr: + return RestApiResponse.jsonError(Http400, InvalidBlockObjectError, + $error) + execution_header_root = hash_tree_root( + restBlock.message.body.execution_payload_header) + + return if execution_header_root in payloadCache: + RestApiResponse.jsonResponse(getSignedUnblindedBeaconBlock( + restBlock, payloadCache[execution_header_root])) + else: + return RestApiResponse.jsonError(Http400, "Unknown execution payload") + + router.api(MethodGet, "/eth/v1/builder/status") do () -> RestApiResponse: + return RestApiResponse.response("", Http200, "text/plain") + +when isMainModule: + let conf = MevMockConf.load() + var router = RestRouter.init(proc(pattern: string, value: string): int = 0) + var payloadCache: TableRef[Eth2Digest, capella.ExecutionPayload] + setupEngineAPI(router, payloadCache) + + let server = RestServerRef.new( + router, initTAddress("127.0.0.1", conf.port)).get() + + server.start() + + when compiles(waitFor waitSignal(SIGINT)): + waitFor waitSignal(SIGINT) + waitFor server.stop() + else: + runForever()