-
Notifications
You must be signed in to change notification settings - Fork 4
feat(eth2api): add submit_proposal_preparations client method
#549
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,9 @@ | ||
| use crate::{ | ||
| BeaconStateFork, ConsensusVersion, EthBeaconNodeApiClient, EventstreamRequestQueryTopic, | ||
| GetForkScheduleRequest, GetForkScheduleResponse, GetGenesisRequest, GetGenesisResponse, | ||
| GetGenesisResponseResponseData, GetSpecRequest, GetSpecResponse, ValidatorStatus, spec::phase0, | ||
| GetGenesisResponseResponseData, GetSpecRequest, GetSpecResponse, PrepareBeaconProposerRequest, | ||
| PrepareBeaconProposerRequestBodyItem, PrepareBeaconProposerResponse, ValidatorStatus, | ||
| spec::{bellatrix, phase0}, | ||
| }; | ||
| use chrono::{DateTime, Utc}; | ||
| use eventsource_stream::Eventsource; | ||
|
|
@@ -59,6 +61,19 @@ pub struct BeaconNodeEvent { | |
| pub data: String, | ||
| } | ||
|
|
||
| /// A single proposal preparation submitted to the beacon node | ||
| /// (`prepare_beacon_proposer`), associating a validator index with the fee | ||
| /// recipient the node should use when building blocks for it. | ||
| /// | ||
| /// Mirrors go-eth2-client's `eth2v1.ProposalPreparation`. | ||
| #[derive(Debug, Clone, PartialEq, Eq)] | ||
| pub struct ProposalPreparation { | ||
| /// Index of the validator the preparation applies to. | ||
| pub validator_index: phase0::ValidatorIndex, | ||
| /// Execution-layer address that should receive block rewards. | ||
| pub fee_recipient: bellatrix::ExecutionAddress, | ||
| } | ||
|
|
||
| // Ordered oldest-to-newest. `resolve_fork_version` relies on this order to | ||
| // break equal-epoch ties (the latest fork wins), so keep it chronological. | ||
| const FORKS: [ConsensusVersion; 6] = [ | ||
|
|
@@ -483,6 +498,35 @@ impl EthBeaconNodeApiClient { | |
|
|
||
| Ok(stream) | ||
| } | ||
|
|
||
| /// Submits proposal preparations to the beacon node | ||
| /// (`POST /eth/v1/validator/prepare_beacon_proposer`). | ||
| /// | ||
| /// Each preparation tells the beacon node which fee recipient to use when | ||
| /// it builds a block for the given validator. The information persists for | ||
| /// the epoch of submission plus the following two epochs, so callers resend | ||
| /// it periodically (e.g. once per epoch). Mirrors go-eth2-client's | ||
| /// `SubmitProposalPreparations`. | ||
| pub async fn submit_proposal_preparations( | ||
| &self, | ||
| preparations: &[ProposalPreparation], | ||
| ) -> Result<(), EthBeaconNodeApiClientError> { | ||
| let body = preparations | ||
| .iter() | ||
| .map(|preparation| PrepareBeaconProposerRequestBodyItem { | ||
| validator_index: preparation.validator_index.to_string(), | ||
| fee_recipient: format!("0x{}", hex::encode(preparation.fee_recipient)), | ||
| }) | ||
| .collect(); | ||
|
|
||
| match self | ||
| .prepare_beacon_proposer(PrepareBeaconProposerRequest { body }) | ||
| .await? | ||
| { | ||
| PrepareBeaconProposerResponse::Ok => Ok(()), | ||
| _ => Err(EthBeaconNodeApiClientError::UnexpectedResponse), | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consistency. Non-2xx collapses to |
||
| } | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
|
|
@@ -672,4 +716,81 @@ mod tests { | |
| assert_eq!(second.topic, "chain_reorg"); | ||
| assert_eq!(second.data, r#"{"slot":"20","depth":"2"}"#); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn submit_proposal_preparations_posts_expected_body() { | ||
| use wiremock::{ | ||
| Mock, MockServer, ResponseTemplate, | ||
| matchers::{body_json, method, path}, | ||
| }; | ||
|
|
||
| let server = MockServer::start().await; | ||
|
|
||
| Mock::given(method("POST")) | ||
| .and(path("/eth/v1/validator/prepare_beacon_proposer")) | ||
| .and(body_json(json!([ | ||
| { | ||
| "validator_index": "1", | ||
| "fee_recipient": "0x0101010101010101010101010101010101010101" | ||
| }, | ||
| { | ||
| "validator_index": "42", | ||
| "fee_recipient": "0x2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a" | ||
| } | ||
| ]))) | ||
| .respond_with(ResponseTemplate::new(200)) | ||
| .expect(1) | ||
| .mount(&server) | ||
| .await; | ||
|
|
||
| let client = EthBeaconNodeApiClient::with_base_url(server.uri()).expect("valid url"); | ||
| client | ||
| .submit_proposal_preparations(&[ | ||
| ProposalPreparation { | ||
| validator_index: 1, | ||
| fee_recipient: [0x01; 20], | ||
| }, | ||
| ProposalPreparation { | ||
| validator_index: 42, | ||
| fee_recipient: [0x2a; 20], | ||
| }, | ||
| ]) | ||
| .await | ||
| .expect("submit succeeds"); | ||
| // The mock's `.expect(1)` verifies on drop that the posted body | ||
| // matched. | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn submit_proposal_preparations_surfaces_error_status() { | ||
| use wiremock::{ | ||
| Mock, MockServer, ResponseTemplate, | ||
| matchers::{method, path}, | ||
| }; | ||
|
|
||
| let server = MockServer::start().await; | ||
|
|
||
| Mock::given(method("POST")) | ||
| .and(path("/eth/v1/validator/prepare_beacon_proposer")) | ||
| .respond_with(ResponseTemplate::new(500).set_body_json(json!({ | ||
| "code": 500, | ||
| "message": "internal error" | ||
| }))) | ||
| .mount(&server) | ||
| .await; | ||
|
|
||
| let client = EthBeaconNodeApiClient::with_base_url(server.uri()).expect("valid url"); | ||
| let error = client | ||
| .submit_proposal_preparations(&[ProposalPreparation { | ||
| validator_index: 1, | ||
| fee_recipient: [0x01; 20], | ||
| }]) | ||
| .await | ||
| .expect_err("a 500 response must surface as an error"); | ||
|
|
||
| assert!(matches!( | ||
| error, | ||
| EthBeaconNodeApiClientError::UnexpectedResponse | ||
| )); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| //! Proposal-preparation recording endpoint used by `BeaconMock`. | ||
| //! | ||
| //! Charon's Go beaconmock swallows `prepare_beacon_proposer` submissions | ||
| //! (testutil/beaconmock/server.go); Pluto instead records them so the | ||
| //! app-level fee-recipient subscriber can be asserted against the mock in | ||
| //! tests. | ||
|
|
||
| use std::sync::{Arc, RwLock}; | ||
|
|
||
| use pluto_eth2api::ProposalPreparation; | ||
| use serde::Deserialize; | ||
| use wiremock::{ | ||
| Mock, MockServer, Request, ResponseTemplate, | ||
| matchers::{method, path}, | ||
| }; | ||
|
|
||
| use super::{ | ||
| defaults::error_response, | ||
| state::{MockState, read_lock, write_lock}, | ||
| }; | ||
|
|
||
| /// Priority for the recording route; lower value (higher priority) than the | ||
| /// default 200 handler in `defaults.rs`, so submissions are captured instead of | ||
| /// silently swallowed. | ||
| const PROPOSAL_PREPARATION_PRIORITY: u8 = 100; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Differs from Charon. Charon's beaconmock no-ops this endpoint ( |
||
|
|
||
| /// Records proposal preparations submitted to | ||
| /// `/eth/v1/validator/prepare_beacon_proposer`. | ||
| #[derive(Debug, Default)] | ||
| pub(crate) struct ProposalPreparationStore { | ||
| submissions: RwLock<Vec<ProposalPreparation>>, | ||
| } | ||
|
|
||
| impl ProposalPreparationStore { | ||
| fn record(&self, preparations: impl IntoIterator<Item = ProposalPreparation>) { | ||
| write_lock(&self.submissions).extend(preparations); | ||
| } | ||
|
|
||
| /// Returns every preparation recorded so far, in submission order. | ||
| pub(crate) fn submissions(&self) -> Vec<ProposalPreparation> { | ||
| read_lock(&self.submissions).clone() | ||
| } | ||
| } | ||
|
|
||
| /// Wire representation of a single `prepare_beacon_proposer` body item. | ||
| #[derive(Debug, Deserialize)] | ||
| struct ProposalPreparationItem { | ||
| validator_index: String, | ||
| fee_recipient: String, | ||
| } | ||
|
|
||
| /// Mounts the recording `prepare_beacon_proposer` handler on `server`. | ||
| pub(crate) async fn mount(server: &MockServer, state: Arc<MockState>) { | ||
| Mock::given(method("POST")) | ||
| .and(path("/eth/v1/validator/prepare_beacon_proposer")) | ||
| .respond_with(move |request: &Request| response(&state, request)) | ||
| .with_priority(PROPOSAL_PREPARATION_PRIORITY) | ||
| .mount(server) | ||
| .await; | ||
| } | ||
|
|
||
| fn response(state: &MockState, request: &Request) -> ResponseTemplate { | ||
| match parse_body(&request.body) { | ||
| Ok(preparations) => { | ||
| state.proposal_preparation_store.record(preparations); | ||
| ResponseTemplate::new(200) | ||
| } | ||
| Err(message) => error_response(400, message), | ||
| } | ||
| } | ||
|
|
||
| fn parse_body(body: &[u8]) -> Result<Vec<ProposalPreparation>, &'static str> { | ||
| let items: Vec<ProposalPreparationItem> = | ||
| serde_json::from_slice(body).map_err(|_| "invalid prepare_beacon_proposer body")?; | ||
|
|
||
| items | ||
| .into_iter() | ||
| .map(|item| { | ||
| let validator_index = item | ||
| .validator_index | ||
| .parse() | ||
| .map_err(|_| "invalid validator_index")?; | ||
| let fee_recipient = parse_execution_address(&item.fee_recipient)?; | ||
| Ok(ProposalPreparation { | ||
| validator_index, | ||
| fee_recipient, | ||
| }) | ||
| }) | ||
| .collect() | ||
| } | ||
|
|
||
| fn parse_execution_address(value: &str) -> Result<[u8; 20], &'static str> { | ||
| let stripped = value.strip_prefix("0x").unwrap_or(value); | ||
| let bytes = hex::decode(stripped).map_err(|_| "invalid fee_recipient hex")?; | ||
| bytes.try_into().map_err(|_| "invalid fee_recipient length") | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use crate::beaconmock::BeaconMock; | ||
| use pluto_eth2api::ProposalPreparation; | ||
| use serde_json::json; | ||
|
|
||
| #[tokio::test] | ||
| async fn records_submitted_preparations() { | ||
| let mock = BeaconMock::builder() | ||
| .build() | ||
| .await | ||
| .expect("build beacon mock"); | ||
|
|
||
| let preparations = vec![ | ||
| ProposalPreparation { | ||
| validator_index: 1, | ||
| fee_recipient: [0x11; 20], | ||
| }, | ||
| ProposalPreparation { | ||
| validator_index: 7, | ||
| fee_recipient: [0x22; 20], | ||
| }, | ||
| ]; | ||
|
|
||
| mock.client() | ||
| .submit_proposal_preparations(&preparations) | ||
| .await | ||
| .expect("submit succeeds"); | ||
|
|
||
| assert_eq!(mock.state().proposal_preparations(), preparations); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn accumulates_across_submissions() { | ||
| let mock = BeaconMock::builder() | ||
| .build() | ||
| .await | ||
| .expect("build beacon mock"); | ||
|
|
||
| let first = ProposalPreparation { | ||
| validator_index: 1, | ||
| fee_recipient: [0x11; 20], | ||
| }; | ||
| let second = ProposalPreparation { | ||
| validator_index: 2, | ||
| fee_recipient: [0x22; 20], | ||
| }; | ||
|
|
||
| mock.client() | ||
| .submit_proposal_preparations(std::slice::from_ref(&first)) | ||
| .await | ||
| .expect("first submit succeeds"); | ||
| mock.client() | ||
| .submit_proposal_preparations(std::slice::from_ref(&second)) | ||
| .await | ||
| .expect("second submit succeeds"); | ||
|
|
||
| assert_eq!(mock.state().proposal_preparations(), vec![first, second]); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn rejects_invalid_body() { | ||
| let mock = BeaconMock::builder() | ||
| .build() | ||
| .await | ||
| .expect("build beacon mock"); | ||
|
|
||
| let response = reqwest::Client::new() | ||
| .post(format!( | ||
| "{}/eth/v1/validator/prepare_beacon_proposer", | ||
| mock.uri() | ||
| )) | ||
| .json(&json!([{ "validator_index": "abc", "fee_recipient": "0x00" }])) | ||
| .send() | ||
| .await | ||
| .expect("send"); | ||
|
|
||
| assert_eq!(response.status(), 400); | ||
| assert!(mock.state().proposal_preparations().is_empty()); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Parity quirk.
fee_recipientis intentionally lowercase0x-hex, not EIP-55 checksummed. This matches go-eth2-client'sExecutionAddress.String()(%#x) that the beacon node receives — deliberately not the checksummingbellatrix::execution_address_serdeused elsewhere in this crate, which is for a different (response) code path. The spec pattern^0x[a-fA-F0-9]{40}$accepts either case.