diff --git a/.github/workflows/bolt_sidecar_ci.yml b/.github/workflows/bolt_sidecar_ci.yml index b485c46d..071200f8 100644 --- a/.github/workflows/bolt_sidecar_ci.yml +++ b/.github/workflows/bolt_sidecar_ci.yml @@ -2,6 +2,9 @@ name: Bolt Sidecar CI on: push: + branches: + - unstable + - main paths: - "bolt-sidecar/**" pull_request: @@ -18,7 +21,7 @@ concurrency: jobs: cargo-tests: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 env: RUST_BACKTRACE: 1 diff --git a/bolt-sidecar/src/builder/template.rs b/bolt-sidecar/src/builder/template.rs index 6d3b5735..5a2d02f2 100644 --- a/bolt-sidecar/src/builder/template.rs +++ b/bolt-sidecar/src/builder/template.rs @@ -8,7 +8,7 @@ use ethereum_consensus::{ crypto::{KzgCommitment, KzgProof}, deneb::mainnet::{Blob, BlobsBundle}, }; -use reth_primitives::TransactionSigned; +use reth_primitives::{TransactionSigned, TxHash}; use tracing::warn; use crate::{ @@ -57,6 +57,15 @@ impl BlockTemplate { .collect() } + /// Get all the transaction hashes in the signed constraints list. + #[inline] + pub fn transaction_hashes(&self) -> Vec { + self.signed_constraints_list + .iter() + .flat_map(|sc| sc.message.transactions.iter().map(|c| *c.hash())) + .collect() + } + /// Converts the list of signed constraints into a list of all blobs in all transactions /// in the constraints. Use this when building a local execution payload. #[inline] diff --git a/bolt-sidecar/src/client/rpc.rs b/bolt-sidecar/src/client/rpc.rs index 5b48abb1..ea685739 100644 --- a/bolt-sidecar/src/client/rpc.rs +++ b/bolt-sidecar/src/client/rpc.rs @@ -8,12 +8,14 @@ use alloy::{ primitives::{Address, Bytes, B256, U256, U64}, rpc::{ client::{self as alloyClient, ClientBuilder}, - types::{Block, FeeHistory}, + types::{Block, FeeHistory, TransactionReceipt}, }, transports::{http::Http, TransportErrorKind, TransportResult}, }; +use futures::{stream::FuturesUnordered, StreamExt}; use reqwest::{Client, Url}; +use reth_primitives::TxHash; use crate::primitives::AccountState; @@ -115,6 +117,33 @@ impl RpcClient { pub async fn send_raw_transaction(&self, raw: Bytes) -> TransportResult { self.0.request("eth_sendRawTransaction", [raw]).await } + + /// Get the receipts for a list of transaction hashes. + pub async fn get_receipts( + &self, + hashes: &[TxHash], + ) -> TransportResult>> { + let mut batch = self.0.new_batch(); + + let futs = FuturesUnordered::new(); + + for hash in hashes { + futs.push( + batch + .add_call("eth_getTransactionReceipt", &(&[hash])) + .expect("Correct parameters"), + ); + } + + batch.send().await?; + + Ok(futs + .collect::>>() + .await + .into_iter() + .map(|r| r.ok()) + .collect()) + } } impl Deref for RpcClient { @@ -161,6 +190,33 @@ mod tests { assert_eq!(account_state.transaction_count, 0); } + #[tokio::test] + #[ignore] + async fn test_get_receipts() { + let _ = tracing_subscriber::fmt().try_init(); + let client = RpcClient::new(Url::from_str("http://localhost:8545").unwrap()); + + let _receipts = client + .get_receipts(&[ + TxHash::from_str( + "0x518d9497868b7380ddfa3d245bead7b418248a0776896f6152590da1bf92c3fe", + ) + .unwrap(), + TxHash::from_str( + "0x6825cfb19d21cc4e69070f4aa506e3de65e09249d38d79b4112f81688bf43379", + ) + .unwrap(), + TxHash::from_str( + "0x5825cfb19d21cc4e69070f4aa506e3de65e09249d38d79b4112f81688bf43379", + ) + .unwrap(), + ]) + .await + .unwrap(); + + println!("{_receipts:?}"); + } + #[tokio::test] #[ignore] async fn test_smart_contract_code() -> eyre::Result<()> { diff --git a/bolt-sidecar/src/state/execution.rs b/bolt-sidecar/src/state/execution.rs index b8241831..2ed49c34 100644 --- a/bolt-sidecar/src/state/execution.rs +++ b/bolt-sidecar/src/state/execution.rs @@ -15,6 +15,7 @@ use crate::{ common::{calculate_max_basefee, max_transaction_cost, validate_transaction}, config::limits::LimitsOpts, primitives::{AccountState, CommitmentRequest, SignedConstraints, Slot}, + telemetry::ApiMetrics, }; use super::fetcher::StateFetcher; @@ -460,7 +461,21 @@ impl ExecutionState { self.apply_state_update(update); // Remove any block templates that are no longer valid - self.remove_block_template(slot); + if let Some(template) = self.remove_block_template(slot) { + debug!(%slot, "Removed block template for slot"); + let hashes = template.transaction_hashes(); + let receipts = self.client.get_receipts(&hashes).await?; + + for receipt in receipts.into_iter().flatten() { + // Calculate the total tip revenue for this transaction: (effective_gas_price - basefee) * gas_used + let tip_per_gas = receipt.effective_gas_price - self.basefee; + let total_tip = tip_per_gas * receipt.gas_used; + + trace!(hash = %receipt.transaction_hash, total_tip, "Receipt found"); + + ApiMetrics::increment_gross_tip_revenue(total_tip); + } + } Ok(()) } diff --git a/bolt-sidecar/src/state/fetcher.rs b/bolt-sidecar/src/state/fetcher.rs index 4576296c..e2a8935b 100644 --- a/bolt-sidecar/src/state/fetcher.rs +++ b/bolt-sidecar/src/state/fetcher.rs @@ -7,10 +7,12 @@ use std::{collections::HashMap, time::Duration}; use alloy::{ eips::BlockNumberOrTag, primitives::{Address, Bytes, U256, U64}, + rpc::types::TransactionReceipt, transports::TransportError, }; use futures::{stream::FuturesOrdered, StreamExt}; use reqwest::Url; +use reth_primitives::TxHash; use tracing::error; use crate::{client::rpc::RpcClient, primitives::AccountState}; @@ -45,6 +47,12 @@ pub trait StateFetcher { ) -> Result; async fn get_chain_id(&self) -> Result; + + /// Gets the receipts for the said list of transaction hashes. IMPORTANT: order is not maintained! + async fn get_receipts( + &self, + hashes: &[TxHash], + ) -> Result>, TransportError>; } /// A basic state fetcher that uses an RPC client to fetch state updates. @@ -208,6 +216,13 @@ impl StateFetcher for StateClient { async fn get_chain_id(&self) -> Result { self.client.get_chain_id().await } + + async fn get_receipts( + &self, + hashes: &[TxHash], + ) -> Result>, TransportError> { + self.client.get_receipts(hashes).await + } } #[cfg(test)] diff --git a/bolt-sidecar/src/telemetry/metrics.rs b/bolt-sidecar/src/telemetry/metrics.rs index 23751b07..f8026bb8 100644 --- a/bolt-sidecar/src/telemetry/metrics.rs +++ b/bolt-sidecar/src/telemetry/metrics.rs @@ -20,6 +20,10 @@ const INCLUSION_COMMITMENTS_ACCEPTED: &str = "bolt_sidecar_inclusion_commitments const TRANSACTIONS_PRECONFIRMED: &str = "bolt_sidecar_transactions_preconfirmed"; /// Counter for the number of validation errors; to spot most the most common ones const VALIDATION_ERRORS: &str = "bolt_sidecar_validation_errors"; +/// Counter that tracks the gross tip revenue. Effective tip per gas * gas used. +/// We call it "gross" because in the case of PBS, it doesn't mean the proposer will +/// get all of this as revenue. +const GROSS_TIP_REVENUE: &str = "bolt_sidecar_gross_tip_revenue"; // Gauges ------------------------------------------------------------------ /// Gauge for the latest slot number @@ -44,6 +48,7 @@ impl ApiMetrics { describe_counter!(INCLUSION_COMMITMENTS_ACCEPTED, "Inclusion commitments accepted"); describe_counter!(TRANSACTIONS_PRECONFIRMED, "Transactions preconfirmed"); describe_counter!(VALIDATION_ERRORS, "Validation errors"); + describe_counter!(GROSS_TIP_REVENUE, "Gross tip revenue"); // Gauges describe_gauge!(LATEST_HEAD, "Latest slot number"); @@ -81,6 +86,25 @@ impl ApiMetrics { counter!(INCLUSION_COMMITMENTS_ACCEPTED).increment(1); } + pub fn increment_gross_tip_revenue(mut tip: u128) { + // If the tip is too large, we need to split it into multiple u64 parts + if tip > u64::MAX as u128 { + let mut parts = Vec::new(); + while tip > u64::MAX as u128 { + parts.push(u64::MAX); + tip -= u64::MAX as u128; + } + + parts.push(tip as u64); + + for part in parts { + counter!(GROSS_TIP_REVENUE).increment(part); + } + } else { + counter!(GROSS_TIP_REVENUE).increment(tip as u64); + } + } + pub fn increment_transactions_preconfirmed(tx_type: TxType) { counter!(TRANSACTIONS_PRECONFIRMED, &[("type", tx_type_str(tx_type))]).increment(1); }