From d56363c5be304cc95744c4b02f0eff7eb85f1f78 Mon Sep 17 00:00:00 2001 From: Ivan Nakov Date: Fri, 25 Sep 2026 15:34:30 +0300 Subject: [PATCH 1/7] feat(types,pool,builder,rpc): count EntryPoint AA errors by stage Add the rundler_entry_point_aa_errors counter, labelled by stage, aa_code and entry_point, so validation failures such as AA26 are visible in Prometheus instead of being folded into generic JSON-RPC error codes. Stages: - estimation: RevertInValidation from eth_estimateUserOperationGas - pool_admission: simulateValidation failure in UoPool::add_operation - bundle_revalidation: op re-simulation during bundle building - bundle_handle_ops: FailedOp from the handleOps simulation - onchain: FailedOp decoded from a reverted bundle transaction The aa_code label is parsed with AaErrorCode::from_message, which only accepts "AA" followed by two digits (otherwise "none"), so the label stays bounded and never carries revert text. The hooks only read the error and do not change control flow, return values or events. --- Cargo.lock | 1 + crates/builder/src/bundle_proposer.rs | 44 +++++-- crates/pool/src/mempool/uo_pool.rs | 12 +- crates/rpc/src/eth/router.rs | 16 ++- crates/sim/src/simulation/mod.rs | 15 ++- crates/types/Cargo.toml | 1 + crates/types/src/entry_point_metrics.rs | 146 ++++++++++++++++++++++++ crates/types/src/lib.rs | 6 +- crates/types/src/pool/error.rs | 59 +++++++++- crates/types/src/validation_results.rs | 107 ++++++++++++++++- 10 files changed, 387 insertions(+), 20 deletions(-) create mode 100644 crates/types/src/entry_point_metrics.rs diff --git a/Cargo.lock b/Cargo.lock index 4d9118a70..18f3126f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5774,6 +5774,7 @@ dependencies = [ "futures-util", "metrics", "metrics-derive", + "metrics-util", "mockall", "num_enum", "parse-display", diff --git a/crates/builder/src/bundle_proposer.rs b/crates/builder/src/bundle_proposer.rs index 8190754ca..bd0c6b1ce 100644 --- a/crates/builder/src/bundle_proposer.rs +++ b/crates/builder/src/bundle_proposer.rs @@ -36,14 +36,15 @@ use rundler_provider::{ }; use rundler_sim::{SimulationError, SimulationResult, Simulator, ViolationError}; use rundler_types::{ - BUNDLE_BYTE_OVERHEAD, BundleExpectedStorage, Entity, EntityInfo, EntityInfos, EntityType, - EntityUpdate, EntityUpdateType, EntryPointAbiVersion, EntryPointVersion, ExpectedStorage, - GasFees, TIME_RANGE_BUFFER, Timestamp, UserOperation, UserOperationVariant, + AaErrorCode, BUNDLE_BYTE_OVERHEAD, BundleExpectedStorage, Entity, EntityInfo, EntityInfos, + EntityType, EntityUpdate, EntityUpdateType, EntryPointAbiVersion, EntryPointVersion, + ExpectedStorage, GasFees, TIME_RANGE_BUFFER, Timestamp, UserOperation, UserOperationVariant, UserOpsPerAggregator, ValidTimeRange, ValidationRevert, aggregator::SignatureAggregatorResult, authorization::Eip7702Auth, chain::ChainSpec, da::{DAGasBlockData, DAGasData}, + entry_point_metrics::{self, AaErrorStage}, pool::{PoolOperation, SimulationViolation}, proxy::SubmissionProxy, }; @@ -515,14 +516,6 @@ where return Ok(RevertOutcome::unattributed(all_op_hashes)); }; - // If we have a submission proxy, use it to process the revert first - if let Some(proxy) = &self.settings.submission_proxy { - let to_remove = proxy.process_revert(revert_data, &ops).await; - if !to_remove.is_empty() { - return Ok(RevertOutcome::remove(to_remove)); - } - } - // Decode the revert using version-specific decoding let handle_ops_out = match self.ep_providers.entry_point().version().abi_version() { EntryPointAbiVersion::V0_6 => { @@ -532,6 +525,23 @@ where decode_v0_7_handle_ops_revert(revert_message, &Some(revert_data.clone())) } }; + // Recorded before the submission proxy, which can attribute the revert itself + if let Some(HandleOpsOut::FailedOp(_, message)) = &handle_ops_out { + entry_point_metrics::record_aa_error( + AaErrorStage::Onchain, + address, + AaErrorCode::from_message(message), + ); + } + + // If we have a submission proxy, use it to process the revert first + if let Some(proxy) = &self.settings.submission_proxy { + let to_remove = proxy.process_revert(revert_data, &ops).await; + if !to_remove.is_empty() { + return Ok(RevertOutcome::remove(to_remove)); + } + } + warn!("Onchain revert data for {tx_hash:?}: {revert_data:?}"); warn!("decoded handle ops out: {handle_ops_out:?}"); @@ -866,6 +876,13 @@ where let simulation = match simulation { Ok(simulation) => simulation, Err(error) => { + if let Some(code) = error.aa_error_code() { + entry_point_metrics::record_aa_error( + AaErrorStage::BundleRevalidation, + *self.ep_providers.entry_point().address(), + code, + ); + } self.emit(BuilderEvent::rejected_op( self.builder_tag.clone(), op.hash(), @@ -1292,6 +1309,11 @@ where Err(BundleProposerError::SimulationUnderpriced) } HandleOpsOut::FailedOp(index, message) => { + entry_point_metrics::record_aa_error( + AaErrorStage::BundleHandleOps, + *self.ep_providers.entry_point().address(), + AaErrorCode::from_message(&message), + ); self.emit(BuilderEvent::rejected_op( self.builder_tag.clone(), context.get_op_at(index)?.op.hash(), diff --git a/crates/pool/src/mempool/uo_pool.rs b/crates/pool/src/mempool/uo_pool.rs index bf5eec98c..8bec3e888 100644 --- a/crates/pool/src/mempool/uo_pool.rs +++ b/crates/pool/src/mempool/uo_pool.rs @@ -28,6 +28,7 @@ use rundler_sim::{MempoolConfig, Prechecker, Simulator}; use rundler_types::{ Entity, EntityUpdate, EntityUpdateType, EntryPointVersion, GasFees, UserOperation, UserOperationId, UserOperationPermissions, UserOperationVariant, + entry_point_metrics::{self, AaErrorStage}, pool::{ BundleOutcome, MempoolError, PaymasterMetadata, PoolOperation, PoolOperationStatus, Reputation, ReputationStatus, StakeStatus, @@ -656,7 +657,16 @@ where .pool_providers .simulator() .simulate_validation(versioned_op, perms.trusted, block_hash, None) - .map_err(Into::into); + .map_err(|error| { + if let Some(code) = error.aa_error_code() { + entry_point_metrics::record_aa_error( + AaErrorStage::PoolAdmission, + self.config.entry_point, + code, + ); + } + MempoolError::from(error) + }); let execution_gas_check_future = self.check_execution_gas_limit_efficiency(op.clone(), block_hash); let (sim_result, _) = tokio::try_join!(sim_fut, execution_gas_check_future)?; diff --git a/crates/rpc/src/eth/router.rs b/crates/rpc/src/eth/router.rs index 803889477..bcdefd553 100644 --- a/crates/rpc/src/eth/router.rs +++ b/crates/rpc/src/eth/router.rs @@ -26,6 +26,7 @@ use rundler_sim::{GasEstimationError, GasEstimator}; use rundler_types::{ EntryPointAbiVersion, EntryPointVersion, GasEstimate, UserOperation, UserOperationOptionalGas, UserOperationVariant, + entry_point_metrics::{self, AaErrorStage}, }; use super::events::{ @@ -381,9 +382,20 @@ where uo: UserOperationOptionalGas, state_override: Option, ) -> Result { - self.gas_estimator + let result = self + .gas_estimator .estimate_op_gas(uo.into(), state_override.unwrap_or_default()) - .await + .await; + + if let Err(GasEstimationError::RevertInValidation(revert)) = &result { + entry_point_metrics::record_aa_error( + AaErrorStage::Estimation, + *self.entry_point.address(), + revert.aa_error_code(), + ); + } + + result } async fn check_signature(&self, uo: UserOperationVariant) -> anyhow::Result { diff --git a/crates/sim/src/simulation/mod.rs b/crates/sim/src/simulation/mod.rs index ab98e8cd2..67fe17571 100644 --- a/crates/sim/src/simulation/mod.rs +++ b/crates/sim/src/simulation/mod.rs @@ -20,7 +20,7 @@ use alloy_primitives::{Address, B256, U256}; use mockall::automock; use rundler_provider::ProviderError; use rundler_types::{ - EntityInfos, ExpectedStorage, UserOperation, ValidTimeRange, + AaErrorCode, EntityInfos, ExpectedStorage, UserOperation, ValidTimeRange, pool::{MempoolError, SimulationViolation}, }; @@ -80,6 +80,19 @@ pub struct SimulationError { pub entity_infos: Option, } +impl SimulationError { + /// Returns the AA code of the first violation that is an entry point + /// validation revert, or `None` if no violation is. + pub fn aa_error_code(&self) -> Option> { + match &self.violation_error { + ViolationError::Violations(violations) => violations + .iter() + .find_map(SimulationViolation::aa_error_code), + ViolationError::Other(_) => None, + } + } +} + impl From for SimulationError { fn from(error: anyhow::Error) -> Self { SimulationError { diff --git a/crates/types/Cargo.toml b/crates/types/Cargo.toml index 323f96349..cb8fa0827 100644 --- a/crates/types/Cargo.toml +++ b/crates/types/Cargo.toml @@ -32,6 +32,7 @@ thiserror.workspace = true [dev-dependencies] alloy-primitives = { workspace = true, features = ["rand"] } +metrics-util = { version = "0.19.0", default-features = false, features = ["debugging"] } serde_json.workspace = true [features] diff --git a/crates/types/src/entry_point_metrics.rs b/crates/types/src/entry_point_metrics.rs new file mode 100644 index 000000000..d2e9d2ba6 --- /dev/null +++ b/crates/types/src/entry_point_metrics.rs @@ -0,0 +1,146 @@ +// This file is part of Rundler. +// +// Rundler is free software: you can redistribute it and/or modify it under the +// terms of the GNU Lesser General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later version. +// +// Rundler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with Rundler. +// If not, see https://www.gnu.org/licenses/. + +//! Metrics for entry point errors, shared across the pool, builder and RPC crates. + +use std::sync::Once; + +use alloy_primitives::Address; +use metrics::Counter; +use metrics_derive::Metrics; + +use crate::validation_results::AaErrorCode; + +/// Where an entry point AA error was observed. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AaErrorStage { + /// Gas estimation (`eth_estimateUserOperationGas`) + Estimation, + /// Validation when an operation is added to the pool (`eth_sendUserOperation`) + PoolAdmission, + /// Re-simulation of a pool operation while building a bundle + BundleRevalidation, + /// Simulation of the full `handleOps` call while building a bundle + BundleHandleOps, + /// A mined bundle transaction that reverted + Onchain, +} + +impl AaErrorStage { + /// Returns the value used for the `stage` label. + pub fn as_str(&self) -> &'static str { + match self { + Self::Estimation => "estimation", + Self::PoolAdmission => "pool_admission", + Self::BundleRevalidation => "bundle_revalidation", + Self::BundleHandleOps => "bundle_handle_ops", + Self::Onchain => "onchain", + } + } +} + +#[derive(Metrics)] +#[metrics(scope = "entry_point")] +struct AaErrorMetrics { + #[metric( + describe = "the count of entry point AA errors, by entry point, stage and AA code (none when the error carries no AA code)." + )] + aa_errors: Counter, +} + +static DESCRIBE: Once = Once::new(); + +/// Counts one entry point AA error. +/// +/// `code` must come from [`AaErrorCode`] so the `aa_code` label stays bounded. +pub fn record_aa_error(stage: AaErrorStage, entry_point: Address, code: AaErrorCode<'_>) { + // `new_with_labels` does not register the metric description + DESCRIBE.call_once(AaErrorMetrics::describe); + + AaErrorMetrics::new_with_labels(&[ + ("stage", stage.as_str().to_string()), + ("aa_code", code.as_label().to_string()), + ("entry_point", entry_point.to_string()), + ]) + .aa_errors + .increment(1); +} + +#[cfg(test)] +mod tests { + use alloy_primitives::address; + use metrics_util::debugging::{DebugValue, DebuggingRecorder}; + + use super::*; + + #[test] + fn test_record_aa_error() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let entry_point = address!("0000000071727De22E5E9d8BAf0edAc6f37da032"); + + metrics::with_local_recorder(&recorder, || { + record_aa_error( + AaErrorStage::PoolAdmission, + entry_point, + AaErrorCode::Code("AA26"), + ); + record_aa_error( + AaErrorStage::PoolAdmission, + entry_point, + AaErrorCode::Code("AA26"), + ); + record_aa_error(AaErrorStage::Onchain, entry_point, AaErrorCode::Uncoded); + }); + + let mut counters = snapshotter + .snapshot() + .into_vec() + .into_iter() + .map(|(key, _, _, value)| { + let key = key.key(); + assert_eq!(key.name(), "entry_point.aa_errors"); + let mut labels = key + .labels() + .map(|label| format!("{}={}", label.key(), label.value())) + .collect::>(); + labels.sort(); + (labels, value) + }) + .collect::>(); + counters.sort_by(|a, b| a.0.cmp(&b.0)); + + let ep = entry_point.to_string(); + assert_eq!( + counters, + vec![ + ( + vec![ + "aa_code=AA26".to_string(), + format!("entry_point={ep}"), + "stage=pool_admission".to_string(), + ], + DebugValue::Counter(2), + ), + ( + vec![ + "aa_code=none".to_string(), + format!("entry_point={ep}"), + "stage=onchain".to_string(), + ], + DebugValue::Counter(1), + ), + ] + ); + } +} diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index 010aacf00..4ebbb4b94 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -54,10 +54,12 @@ pub use storage::*; mod validation_results; pub use validation_results::{ - AggregatorInfo, StakeInfo, ValidationOutput, ValidationReturnInfo, ValidationRevert, - parse_validation_data, + AaErrorCode, AggregatorInfo, StakeInfo, ValidationOutput, ValidationReturnInfo, + ValidationRevert, parse_validation_data, }; +pub mod entry_point_metrics; + pub mod task; pub mod authorization; diff --git a/crates/types/src/pool/error.rs b/crates/types/src/pool/error.rs index 4729fa00b..1502fad2e 100644 --- a/crates/types/src/pool/error.rs +++ b/crates/types/src/pool/error.rs @@ -15,7 +15,7 @@ use alloy_primitives::{Address, U256}; use crate::{ Entity, EntityType, StorageSlot, Timestamp, ViolationOpCode, - validation_results::ValidationRevert, + validation_results::{AaErrorCode, ValidationRevert}, }; /// Pool server error type @@ -290,6 +290,22 @@ pub enum SimulationViolation { AccessedUnsupportedContractType(String, Address), } +impl SimulationViolation { + /// Returns the AA code if this violation is an entry point validation revert, + /// or `None` for violations detected by the bundler itself. + pub fn aa_error_code(&self) -> Option> { + match self { + Self::ValidationRevert(revert) => Some(revert.aa_error_code()), + // v0.6 safe simulation reports entry point reverts without a ValidationRevert + Self::UnintendedRevertWithMessage(_, message, _) => { + Some(AaErrorCode::from_message(message)) + } + Self::UnintendedRevert(_, _) => Some(AaErrorCode::Uncoded), + _ => None, + } + } +} + /// Information about a storage violation based on stake status #[derive(Debug, PartialEq, Clone, PartialOrd, Eq, Ord)] pub struct NeedsStakeInformation { @@ -308,3 +324,44 @@ pub struct NeedsStakeInformation { /// Minumum delay after an unstake event pub min_unstake_delay: u32, } + +#[cfg(test)] +mod tests { + use alloy_primitives::Bytes; + + use super::*; + + #[test] + fn test_simulation_violation_aa_error_code() { + let validation_revert = + SimulationViolation::ValidationRevert(ValidationRevert::Operation { + entry_point_reason: "AA33 reverted".to_string(), + inner_revert_data: Bytes::new(), + inner_revert_reason: None, + }); + assert_eq!( + validation_revert.aa_error_code(), + Some(AaErrorCode::Code("AA33")) + ); + + let unknown_revert = + SimulationViolation::ValidationRevert(ValidationRevert::Unknown(Bytes::new())); + assert_eq!(unknown_revert.aa_error_code(), Some(AaErrorCode::Uncoded)); + + let v0_6_revert = SimulationViolation::UnintendedRevertWithMessage( + EntityType::Account, + "AA23 reverted (or OOG)".to_string(), + None, + ); + assert_eq!(v0_6_revert.aa_error_code(), Some(AaErrorCode::Code("AA23"))); + + let v0_6_unknown_revert = SimulationViolation::UnintendedRevert(EntityType::Account, None); + assert_eq!( + v0_6_unknown_revert.aa_error_code(), + Some(AaErrorCode::Uncoded) + ); + + assert_eq!(SimulationViolation::CodeHashChanged.aa_error_code(), None); + assert_eq!(SimulationViolation::InvalidSignature.aa_error_code(), None); + } +} diff --git a/crates/types/src/validation_results.rs b/crates/types/src/validation_results.rs index ea3dcf1c1..be3b3e0ff 100644 --- a/crates/types/src/validation_results.rs +++ b/crates/types/src/validation_results.rs @@ -93,6 +93,18 @@ impl ValidationRevert { .map(|m| &m[..4]) } + /// Returns the AA code carried by this revert. + pub fn aa_error_code(&self) -> AaErrorCode<'_> { + match self { + Self::EntryPoint(message) + | Self::Operation { + entry_point_reason: message, + .. + } => AaErrorCode::from_message(message), + Self::Unknown(_) | Self::Panic(_) => AaErrorCode::Uncoded, + } + } + fn display_operation_error( entry_point_message: &str, inner_message: &Option, @@ -104,6 +116,41 @@ impl ValidationRevert { } } +/// The AA code carried by an entry point validation revert. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AaErrorCode<'a> { + /// A well-formed code, e.g. `"AA26"`. + Code(&'a str), + /// An entry point revert without an AA code prefix. + Uncoded, +} + +impl<'a> AaErrorCode<'a> { + /// Parses the code from the start of an entry point revert reason, e.g. + /// `"AA26 over verificationGasLimit"`. + /// + /// Only `AA` followed by two ASCII digits is accepted, so the result is + /// bounded and safe to use as a metric label. + pub fn from_message(message: &'a str) -> Self { + match message.get(..4) { + Some(code) + if code.starts_with("AA") && code.bytes().skip(2).all(|b| b.is_ascii_digit()) => + { + Self::Code(code) + } + _ => Self::Uncoded, + } + } + + /// Returns the value to use as a metric label. + pub fn as_label(&self) -> &'a str { + match self { + Self::Code(code) => code, + Self::Uncoded => "none", + } + } +} + impl From for ValidationRevert { fn from(value: Revert) -> Self { ValidationRevert::EntryPoint(value.reason) @@ -460,9 +507,10 @@ impl TryFrom for AggregatorInfo { #[cfg(test)] mod tests { - use alloy_primitives::{address, uint}; + use alloy_primitives::{Bytes, address, uint}; + use alloy_sol_types::Panic; - use super::parse_validation_data; + use super::{AaErrorCode, ValidationRevert, parse_validation_data}; #[test] fn test_parse_validation_data() { @@ -489,4 +537,59 @@ mod tests { assert_eq!(parsed.valid_until, u64::MAX); assert_eq!(parsed.valid_after, 0x001122334455); } + + #[test] + fn test_aa_error_code_from_message() { + assert_eq!( + AaErrorCode::from_message("AA26 over verificationGasLimit"), + AaErrorCode::Code("AA26") + ); + // v0.7 FailedOpWithRevert is flattened to "{reason}:{inner}" + assert_eq!( + AaErrorCode::from_message("AA23 reverted:0x1234"), + AaErrorCode::Code("AA23") + ); + for message in [ + "", + "AA", + "AA2", + "AAzz reverted", + "Aggregator signature validation failed", + ] { + assert_eq!(AaErrorCode::from_message(message), AaErrorCode::Uncoded); + } + } + + #[test] + fn test_aa_error_code_multibyte_does_not_panic() { + assert_eq!(AaErrorCode::from_message("AA€x"), AaErrorCode::Uncoded); + assert_eq!(AaErrorCode::from_message("AA2€"), AaErrorCode::Uncoded); + } + + #[test] + fn test_aa_error_code_label() { + assert_eq!(AaErrorCode::Code("AA25").as_label(), "AA25"); + assert_eq!(AaErrorCode::Uncoded.as_label(), "none"); + } + + #[test] + fn test_validation_revert_aa_error_code() { + let entry_point = ValidationRevert::EntryPoint("AA25 invalid account nonce".to_string()); + assert_eq!(entry_point.aa_error_code(), AaErrorCode::Code("AA25")); + assert_eq!(entry_point.entry_point_error_code(), Some("AA25")); + + let operation = ValidationRevert::Operation { + entry_point_reason: "AA23 reverted".to_string(), + inner_revert_data: Bytes::new(), + inner_revert_reason: Some("AA99 not the entry point".to_string()), + }; + assert_eq!(operation.aa_error_code(), AaErrorCode::Code("AA23")); + + let unknown = ValidationRevert::Unknown(Bytes::from_static(&[1, 2, 3])); + assert_eq!(unknown.aa_error_code(), AaErrorCode::Uncoded); + assert_eq!(unknown.entry_point_error_code(), None); + + let panic = ValidationRevert::Panic(Panic::from(0x11_u64)); + assert_eq!(panic.aa_error_code(), AaErrorCode::Uncoded); + } } From 1d33b8320ef2e39664d80d801c6e4e30f33de020 Mon Sep 17 00:00:00 2001 From: Ivan Nakov Date: Fri, 25 Sep 2026 18:37:23 +0300 Subject: [PATCH 2/7] feat(pool): record gas limit efficiency of mined user operations Add rundler_op_pool_mined_op_gas_efficiency, a histogram of actualGasUsed / gas limit for each mined op that was in the pool. The limit is what the entry point budgets for the prefund, including preVerificationGas (v0.6 counts the verification gas limit 3x with a paymaster). Ops whose pool copy has a different hash are skipped. Labels are entry_point, success, has_paymaster, has_factory, has_7702_auth and fresh_nonce_slot, all booleans except the entry point. success=false exposes call-phase failures, which never carry an AA code on v0.7+. MinedOp now keeps success and actualGasUsed from UserOperationEvent. The histogram gets ratio buckets instead of the global millisecond buckets. --- Cargo.lock | 1 + bin/rundler/src/cli/metrics.rs | 9 +- crates/pool/Cargo.toml | 1 + crates/pool/src/chain.rs | 51 +++++ crates/pool/src/lib.rs | 2 +- crates/pool/src/mempool/gas_metrics.rs | 278 +++++++++++++++++++++++++ crates/pool/src/mempool/mod.rs | 4 + crates/pool/src/mempool/pool.rs | 4 + crates/pool/src/mempool/uo_pool.rs | 91 +++++++- 9 files changed, 438 insertions(+), 3 deletions(-) create mode 100644 crates/pool/src/mempool/gas_metrics.rs diff --git a/Cargo.lock b/Cargo.lock index 18f3126f2..f87a70e2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5577,6 +5577,7 @@ dependencies = [ "itertools 0.13.0", "metrics", "metrics-derive", + "metrics-util", "mockall", "parking_lot", "prost 0.13.4", diff --git a/bin/rundler/src/cli/metrics.rs b/bin/rundler/src/cli/metrics.rs index 1b229e492..0aba2448e 100644 --- a/bin/rundler/src/cli/metrics.rs +++ b/bin/rundler/src/cli/metrics.rs @@ -16,7 +16,7 @@ use std::{net::SocketAddr, time::Duration}; use itertools::Itertools; use metrics::Gauge; use metrics_derive::Metrics; -use metrics_exporter_prometheus::PrometheusBuilder; +use metrics_exporter_prometheus::{Matcher, PrometheusBuilder}; use metrics_process::Collector; use metrics_util::layers::{PrefixLayer, Stack}; use rundler_task::TaskSpawner; @@ -39,6 +39,13 @@ pub fn initialize<'a, T: TaskSpawner>( } builder = builder.set_buckets(buckets)?; + // ratio histograms need their own buckets, the global ones are sized for milliseconds + for name in rundler_pool::GAS_EFFICIENCY_HISTOGRAMS { + builder = builder.set_buckets_for_metric( + Matcher::Full(format!("rundler.{name}")), + rundler_pool::GAS_EFFICIENCY_BUCKETS, + )?; + } let (recorder, exporter) = builder.build()?; task_spawner.spawn_critical( diff --git a/crates/pool/Cargo.toml b/crates/pool/Cargo.toml index b72dc82db..40b5c72f0 100644 --- a/crates/pool/Cargo.toml +++ b/crates/pool/Cargo.toml @@ -44,6 +44,7 @@ alloy-eips.workspace = true alloy-network-primitives.workspace = true alloy-rpc-types-eth.workspace = true alloy-serde.workspace = true +metrics-util = { version = "0.19.0", default-features = false, features = ["debugging"] } mockall.workspace = true reth-tasks.workspace = true rundler-provider = { workspace = true, features = ["test-utils"] } diff --git a/crates/pool/src/chain.rs b/crates/pool/src/chain.rs index ef26aaa1e..f040ca44d 100644 --- a/crates/pool/src/chain.rs +++ b/crates/pool/src/chain.rs @@ -131,6 +131,8 @@ pub(crate) struct MinedOp { pub nonce: U256, pub actual_gas_cost: U256, pub paymaster: Option
, + pub success: bool, + pub actual_gas_used: U256, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -938,6 +940,8 @@ impl Chain

{ nonce: event.nonce, actual_gas_cost: event.actualGasCost, paymaster, + success: event.success, + actual_gas_used: event.actualGasUsed, }; mined_ops.push(mined); } @@ -1000,6 +1004,8 @@ impl Chain

{ nonce: event.nonce, actual_gas_cost: event.actualGasCost, paymaster, + success: event.success, + actual_gas_used: event.actualGasUsed, }; mined_ops.push(mined); } @@ -2430,6 +2436,49 @@ mod tests { } } + #[test] + fn test_load_mined_op_keeps_success_and_actual_gas_used() { + let op_hash = hash(1); + let actual_gas_used = U256::from(123_456); + + let mut log_v0_6 = fake_mined_log_v0_6(op_hash); + log_v0_6.inner.data.data = UserOperationEventV06 { + userOpHash: op_hash, + sender: Address::ZERO, + paymaster: Address::ZERO, + nonce: U256::ZERO, + success: false, + actualGasCost: U256::ZERO, + actualGasUsed: actual_gas_used, + } + .encode_data() + .into(); + + let mut log_v0_7 = fake_mined_log_v0_7(op_hash); + log_v0_7.inner.data.data = UserOperationEventV07 { + userOpHash: op_hash, + sender: Address::ZERO, + paymaster: Address::ZERO, + nonce: U256::ZERO, + success: false, + actualGasCost: U256::ZERO, + actualGasUsed: actual_gas_used, + } + .encode_data() + .into(); + + let mut mined_ops = vec![]; + let mut balance_updates = vec![]; + Chain::::load_v0_6(log_v0_6, &mut mined_ops, &mut balance_updates); + Chain::::load_v0_7(log_v0_7, &mut mined_ops, &mut balance_updates); + + assert_eq!(mined_ops.len(), 2); + for mined_op in mined_ops { + assert!(!mined_op.success); + assert_eq!(mined_op.actual_gas_used, actual_gas_used); + } + } + fn fake_mined_op(n: u8, ep: Address) -> MinedOp { MinedOp { hash: hash(n), @@ -2438,6 +2487,8 @@ mod tests { nonce: U256::ZERO, actual_gas_cost: U256::ZERO, paymaster: None, + success: true, + actual_gas_used: U256::ZERO, } } diff --git a/crates/pool/src/lib.rs b/crates/pool/src/lib.rs index 36a85b7e8..ea8364bab 100644 --- a/crates/pool/src/lib.rs +++ b/crates/pool/src/lib.rs @@ -25,7 +25,7 @@ mod emit; pub use emit::OpPoolEvent as PoolEvent; mod mempool; -pub use mempool::PoolConfig; +pub use mempool::{GAS_EFFICIENCY_BUCKETS, GAS_EFFICIENCY_HISTOGRAMS, PoolConfig}; mod server; pub use server::{LocalPoolBuilder, LocalPoolHandle, RemotePoolClient}; diff --git a/crates/pool/src/mempool/gas_metrics.rs b/crates/pool/src/mempool/gas_metrics.rs new file mode 100644 index 000000000..6ed41c063 --- /dev/null +++ b/crates/pool/src/mempool/gas_metrics.rs @@ -0,0 +1,278 @@ +// This file is part of Rundler. +// +// Rundler is free software: you can redistribute it and/or modify it under the +// terms of the GNU Lesser General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later version. +// +// Rundler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with Rundler. +// If not, see https://www.gnu.org/licenses/. + +//! Metrics comparing user operation gas limits with the gas actually used. + +use std::sync::Once; + +use alloy_primitives::{Address, U256}; +use metrics::Histogram; +use metrics_derive::Metrics; +use rundler_types::{UserOperation, UserOperationVariant}; + +/// Names of the gas efficiency histograms, without the global metrics prefix. +pub const GAS_EFFICIENCY_HISTOGRAMS: &[&str] = &["op_pool.mined_op_gas_efficiency"]; + +/// Histogram buckets for the used / limit ratios, denser near 1.0 where limits are tight. +pub const GAS_EFFICIENCY_BUCKETS: &[f64] = &[ + 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.85, 0.9, 0.95, 0.98, 1.0, 1.05, +]; + +/// Bounded labels describing which parts of an operation may write new state. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct OpGasLabels { + has_paymaster: bool, + has_factory: bool, + has_7702_auth: bool, + fresh_nonce_slot: bool, +} + +impl OpGasLabels { + fn from_op(uo: &UserOperationVariant) -> Self { + Self { + has_paymaster: uo.paymaster().is_some(), + has_factory: uo.factory().is_some(), + has_7702_auth: uo.authorization_tuple().is_some(), + // the low 64 bits are the sequence of the nonce key, 0 means its slot is still empty + fresh_nonce_slot: (uo.nonce() & U256::from(u64::MAX)).is_zero(), + } + } + + fn to_labels(self, entry_point: Address) -> Vec<(&'static str, String)> { + vec![ + ("entry_point", entry_point.to_string()), + ("has_paymaster", self.has_paymaster.to_string()), + ("has_factory", self.has_factory.to_string()), + ("has_7702_auth", self.has_7702_auth.to_string()), + ("fresh_nonce_slot", self.fresh_nonce_slot.to_string()), + ] + } +} + +/// The gas the entry point budgets for an operation when computing its required prefund. +fn entry_point_gas_limit(uo: &UserOperationVariant) -> u128 { + match uo { + // v0.6 applies the verification gas limit to validation, paymaster validation and postOp + UserOperationVariant::V0_6(op) => { + let mul = if op.paymaster().is_some() { 3 } else { 1 }; + op.pre_verification_gas() + .saturating_add(op.call_gas_limit()) + .saturating_add(op.verification_gas_limit().saturating_mul(mul)) + } + UserOperationVariant::V0_7(op) => op.total_gas_limit(), + } +} + +fn efficiency(used: u128, limit: u128) -> Option { + if limit == 0 { + return None; + } + Some(used as f64 / limit as f64) +} + +#[derive(Metrics)] +#[metrics(scope = "op_pool")] +struct MinedOpGasMetrics { + #[metric( + describe = "the fraction of the gas limit, including preVerificationGas, a mined op used according to its UserOperationEvent. has_7702_auth means the op carried an authorization, not necessarily a first delegation." + )] + mined_op_gas_efficiency: Histogram, +} + +static DESCRIBE_MINED: Once = Once::new(); + +/// Records how much of its gas limit a mined operation used. +/// +/// `uo` must be the operation that was mined, not a replacement with the same id. +pub(crate) fn record_mined_op_gas_efficiency( + entry_point: Address, + uo: &UserOperationVariant, + success: bool, + actual_gas_used: U256, +) { + let Some(ratio) = efficiency( + actual_gas_used.saturating_to::(), + entry_point_gas_limit(uo), + ) else { + return; + }; + + // `new_with_labels` does not register the metric description + DESCRIBE_MINED.call_once(MinedOpGasMetrics::describe); + + let mut labels = OpGasLabels::from_op(uo).to_labels(entry_point); + labels.push(("success", success.to_string())); + MinedOpGasMetrics::new_with_labels(&labels) + .mined_op_gas_efficiency + .record(ratio); +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Bytes, address}; + use metrics_util::debugging::{DebugValue, DebuggingRecorder}; + use rundler_types::{ + EntryPointVersion, authorization::Eip7702Auth, chain::ChainSpec, v0_6, v0_7, + }; + + use super::*; + + const ENTRY_POINT: Address = address!("0000000071727De22E5E9d8BAf0edAc6f37da032"); + const PAYMASTER: Address = address!("00000000000000000000000000000000000000aa"); + const FACTORY: Address = address!("00000000000000000000000000000000000000bb"); + + fn v0_7_builder(chain_spec: &ChainSpec, nonce: U256) -> v0_7::UserOperationBuilder<'_> { + v0_7::UserOperationBuilder::new( + chain_spec, + EntryPointVersion::V0_7, + v0_7::UserOperationRequiredFields { + sender: Address::ZERO, + nonce, + call_data: Bytes::new(), + call_gas_limit: 100_000, + verification_gas_limit: 200_000, + pre_verification_gas: 50_000, + max_priority_fee_per_gas: 1, + max_fee_per_gas: 1, + signature: Bytes::new(), + }, + ) + } + + fn v0_6_op(paymaster: bool) -> UserOperationVariant { + let chain_spec = ChainSpec::default(); + let paymaster_and_data = if paymaster { + Bytes::copy_from_slice(PAYMASTER.as_slice()) + } else { + Bytes::new() + }; + v0_6::UserOperationBuilder::new( + &chain_spec, + v0_6::UserOperationRequiredFields { + call_gas_limit: 100_000, + verification_gas_limit: 200_000, + pre_verification_gas: 50_000, + paymaster_and_data, + ..Default::default() + }, + ) + .build() + .into() + } + + #[test] + fn test_labels_from_op() { + let chain_spec = ChainSpec::default(); + + let plain: UserOperationVariant = v0_7_builder(&chain_spec, U256::from(1)).build().into(); + assert_eq!( + OpGasLabels::from_op(&plain), + OpGasLabels { + has_paymaster: false, + has_factory: false, + has_7702_auth: false, + fresh_nonce_slot: false, + } + ); + + let full: UserOperationVariant = v0_7_builder(&chain_spec, U256::ZERO) + .paymaster(PAYMASTER, 10_000, 10_000, Bytes::new()) + .factory(FACTORY, Bytes::new()) + .authorization_tuple(Eip7702Auth::default()) + .build() + .into(); + assert_eq!( + OpGasLabels::from_op(&full), + OpGasLabels { + has_paymaster: true, + has_factory: true, + has_7702_auth: true, + fresh_nonce_slot: true, + } + ); + } + + #[test] + fn test_fresh_nonce_slot() { + let chain_spec = ChainSpec::default(); + let fresh = |nonce: U256| { + let op: UserOperationVariant = v0_7_builder(&chain_spec, nonce).build().into(); + OpGasLabels::from_op(&op).fresh_nonce_slot + }; + + let key = U256::from(7) << 64; + assert!(fresh(U256::ZERO)); + assert!(!fresh(U256::from(1))); + assert!(fresh(key)); + assert!(!fresh(key + U256::from(1))); + } + + #[test] + fn test_entry_point_gas_limit() { + // pvg + cgl + vgl + assert_eq!(entry_point_gas_limit(&v0_6_op(false)), 350_000); + // pvg + cgl + 3 * vgl + assert_eq!(entry_point_gas_limit(&v0_6_op(true)), 750_000); + + let chain_spec = ChainSpec::default(); + let v0_7_op: UserOperationVariant = v0_7_builder(&chain_spec, U256::ZERO) + .paymaster(PAYMASTER, 30_000, 20_000, Bytes::new()) + .build() + .into(); + // pvg + vgl + pm vgl + cgl + pm postOp + assert_eq!(entry_point_gas_limit(&v0_7_op), 400_000); + } + + #[test] + fn test_efficiency() { + assert_eq!(efficiency(50, 100), Some(0.5)); + assert_eq!(efficiency(0, 0), None); + } + + #[test] + fn test_record_mined_op_gas_efficiency() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let chain_spec = ChainSpec::default(); + let op: UserOperationVariant = v0_7_builder(&chain_spec, U256::from(1)).build().into(); + + metrics::with_local_recorder(&recorder, || { + record_mined_op_gas_efficiency(ENTRY_POINT, &op, false, U256::from(175_000)); + }); + + let snapshot = snapshotter.snapshot().into_vec(); + assert_eq!(snapshot.len(), 1); + let (key, _, _, value) = &snapshot[0]; + let key = key.key(); + assert_eq!(key.name(), GAS_EFFICIENCY_HISTOGRAMS[0]); + + let mut labels = key + .labels() + .map(|label| format!("{}={}", label.key(), label.value())) + .collect::>(); + labels.sort(); + assert_eq!( + labels, + vec![ + format!("entry_point={ENTRY_POINT}"), + "fresh_nonce_slot=false".to_string(), + "has_7702_auth=false".to_string(), + "has_factory=false".to_string(), + "has_paymaster=false".to_string(), + "success=false".to_string(), + ] + ); + // 175_000 used of 350_000 + assert_eq!(value, &DebugValue::Histogram(vec![0.5.into()])); + } +} diff --git a/crates/pool/src/mempool/mod.rs b/crates/pool/src/mempool/mod.rs index c5daf0731..44129de02 100644 --- a/crates/pool/src/mempool/mod.rs +++ b/crates/pool/src/mempool/mod.rs @@ -12,6 +12,10 @@ // If not, see https://www.gnu.org/licenses/. mod entity_tracker; + +mod gas_metrics; +pub use gas_metrics::{GAS_EFFICIENCY_BUCKETS, GAS_EFFICIENCY_HISTOGRAMS}; + mod pool; mod reputation; diff --git a/crates/pool/src/mempool/pool.rs b/crates/pool/src/mempool/pool.rs index 84f1a77de..608c1c563 100644 --- a/crates/pool/src/mempool/pool.rs +++ b/crates/pool/src/mempool/pool.rs @@ -1508,6 +1508,8 @@ mod tests { entry_point: pool.config.entry_point, sender, nonce: U256::from(nonce), + success: true, + actual_gas_used: U256::ZERO, }; pool.mine_operation(&mined_op, 1); @@ -1544,6 +1546,8 @@ mod tests { entry_point: pool.config.entry_point, sender, nonce: U256::from(nonce), + success: true, + actual_gas_used: U256::ZERO, }; pool.mine_operation(&mined_op, 1); diff --git a/crates/pool/src/mempool/uo_pool.rs b/crates/pool/src/mempool/uo_pool.rs index 8bec3e888..691af4543 100644 --- a/crates/pool/src/mempool/uo_pool.rs +++ b/crates/pool/src/mempool/uo_pool.rs @@ -40,7 +40,7 @@ use tonic::async_trait; use tracing::{info, instrument}; use super::{ - Mempool, MempoolResult, OperationOrigin, PoolConfig, paymaster::PaymasterTracker, + Mempool, MempoolResult, OperationOrigin, PoolConfig, gas_metrics, paymaster::PaymasterTracker, pool::PoolInner, reputation::AddressReputation, }; use crate::{ @@ -347,6 +347,15 @@ where for entity_addr in pool_op.entities().map(|e| e.address).unique() { self.reputation.add_included(entity_addr); } + // The pool copy can be a replacement with different limits, see Pool::mine_operation + if pool_op.uo.hash() == op.hash { + gas_metrics::record_mined_op_gas_efficiency( + self.config.entry_point, + &pool_op.uo, + op.success, + op.actual_gas_used, + ); + } mined_op_count += 1; } } @@ -1102,6 +1111,7 @@ mod tests { use alloy_rpc_types_eth::TransactionReceipt as AlloyTransactionReceipt; use alloy_serde::WithOtherFields; use alloy_sol_types::SolEvent; + use metrics_util::debugging::{DebugValue, DebuggingRecorder}; use mockall::Sequence; use rundler_contracts::v0_6::IEntryPoint::UserOperationEvent as UserOperationEventV06; use rundler_provider::{ @@ -1323,6 +1333,8 @@ mod tests { nonce: uos[0].nonce(), actual_gas_cost: U256::ZERO, paymaster: None, + success: true, + actual_gas_used: U256::ZERO, }], unmined_ops: vec![], preconfirmed_txns: vec![], @@ -1425,6 +1437,8 @@ mod tests { nonce: uos[0].nonce(), actual_gas_cost: U256::from(10), paymaster: Some(paymaster), + success: true, + actual_gas_used: U256::ZERO, }], unmined_ops: vec![], @@ -1470,6 +1484,8 @@ mod tests { nonce: uos[0].nonce(), actual_gas_cost: U256::from(10), paymaster: None, + success: true, + actual_gas_used: U256::ZERO, }], entity_balance_updates: vec![], unmined_entity_balance_updates: vec![BalanceUpdate { @@ -1515,6 +1531,8 @@ mod tests { nonce: uos[0].nonce(), actual_gas_cost: U256::ZERO, paymaster: None, + success: true, + actual_gas_used: U256::ZERO, }], unmined_ops: vec![], preconfirmed_txns: vec![], @@ -1561,6 +1579,8 @@ mod tests { nonce: uos[0].nonce(), actual_gas_cost: U256::ZERO, paymaster: None, + success: true, + actual_gas_used: U256::ZERO, }], unmined_ops: vec![], preconfirmed_txns: vec![], @@ -1580,6 +1600,73 @@ mod tests { assert_eq!(rep[0].ops_included, 1); // 1 op included } + #[tokio::test] + async fn test_mined_op_gas_efficiency() { + let ops = (0..2) + .map(|_| { + create_op_from_required(UserOperationRequiredFields { + sender: Address::random(), + call_gas_limit: 100_000, + verification_gas_limit: 50_000, + pre_verification_gas: 50_000, + max_fee_per_gas: 1, + ..Default::default() + }) + }) + .collect(); + let (pool, uos) = create_pool_insert_ops(ops).await; + check_ops_unordered(&pool.best_operations(2, None).unwrap(), &uos); + + let mined_op = |uo: &UserOperationVariant, hash: B256| MinedOp { + entry_point: pool.config.entry_point, + hash, + sender: uo.sender(), + nonce: uo.nonce(), + actual_gas_cost: U256::ZERO, + paymaster: None, + success: true, + actual_gas_used: U256::from(160_000), + }; + + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + pool.on_chain_update(&ChainUpdate { + latest_block_number: 1, + latest_block_hash: B256::random(), + latest_block_timestamp: 0.into(), + earliest_remembered_block_number: 0, + reorg_depth: 0, + mined_ops: vec![ + mined_op(&uos[0], uos[0].hash()), + // same id as the pool op but a different hash, e.g. a replaced op + mined_op(&uos[1], B256::random()), + ], + unmined_ops: vec![], + preconfirmed_txns: vec![], + preconfirmed_block_number: None, + entity_balance_updates: vec![], + unmined_entity_balance_updates: vec![], + address_updates: vec![], + reorg_larger_than_history: false, + update_type: UpdateType::Confirmed, + }) + .await; + + // both ops are removed from the pool, only the one with a matching hash is recorded + assert!(pool.best_operations(2, None).unwrap().is_empty()); + let samples = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter(|(key, _, _, _)| key.key().name() == gas_metrics::GAS_EFFICIENCY_HISTOGRAMS[0]) + .map(|(_, _, _, value)| value) + .collect::>(); + // 160_000 used of 200_000 + assert_eq!(samples, vec![DebugValue::Histogram(vec![0.8.into()])]); + } + #[tokio::test] async fn test_throttled_account() { let address = Address::random(); @@ -1641,6 +1728,8 @@ mod tests { nonce: uos[0].nonce(), actual_gas_cost: U256::ZERO, paymaster: None, + success: true, + actual_gas_used: U256::ZERO, }], entity_balance_updates: vec![], preconfirmed_txns: vec![], From 80d4ef6ae51e0c2b2603be2222ec607511f23966 Mon Sep 17 00:00:00 2001 From: Ivan Nakov Date: Fri, 25 Sep 2026 18:38:14 +0300 Subject: [PATCH 3/7] feat(pool): record verification gas efficiency at pool admission Add rundler_op_pool_admission_verification_gas_efficiency, a histogram of (pre_op_gas - preVerificationGas) / total verification gas limit from the pool admission simulation, with the same op labels as the mined op histogram minus success. The value is recorded on the scale of the reject threshold: for v0.6 ops with a paymaster, where the check halves the threshold, it is measured against one verification gas limit and can exceed 1. An op is rejected exactly when its recorded value is below the configured threshold. The existing efficiency check only computed this ratio when its reject threshold is above 0 (default 0). The ratio is now computed once by gas_metrics::verification_gas_efficiency, recorded for every op that passes simulation, including ops the threshold then rejects, and reused by the check, whose outcome is unchanged. An op with a zero verification gas limit is still accepted and is not recorded. --- crates/pool/src/mempool/gas_metrics.rs | 132 ++++++++++++++++++++++++- crates/pool/src/mempool/uo_pool.rs | 76 ++++++++++++-- 2 files changed, 199 insertions(+), 9 deletions(-) diff --git a/crates/pool/src/mempool/gas_metrics.rs b/crates/pool/src/mempool/gas_metrics.rs index 6ed41c063..bb9de513d 100644 --- a/crates/pool/src/mempool/gas_metrics.rs +++ b/crates/pool/src/mempool/gas_metrics.rs @@ -21,7 +21,10 @@ use metrics_derive::Metrics; use rundler_types::{UserOperation, UserOperationVariant}; /// Names of the gas efficiency histograms, without the global metrics prefix. -pub const GAS_EFFICIENCY_HISTOGRAMS: &[&str] = &["op_pool.mined_op_gas_efficiency"]; +pub const GAS_EFFICIENCY_HISTOGRAMS: &[&str] = &[ + "op_pool.mined_op_gas_efficiency", + "op_pool.admission_verification_gas_efficiency", +]; /// Histogram buckets for the used / limit ratios, denser near 1.0 where limits are tight. pub const GAS_EFFICIENCY_BUCKETS: &[f64] = &[ @@ -117,6 +120,48 @@ pub(crate) fn record_mined_op_gas_efficiency( .record(ratio); } +#[derive(Metrics)] +#[metrics(scope = "op_pool")] +struct AdmissionGasMetrics { + #[metric( + describe = "the fraction of the verification gas limit an op used in pool admission simulation, on the scale of verification_gas_limit_efficiency_reject_threshold: v0.6 ops with a paymaster are measured against one verification gas limit, so can exceed 1. has_7702_auth means the op carried an authorization, not necessarily a first delegation." + )] + admission_verification_gas_efficiency: Histogram, +} + +static DESCRIBE_ADMISSION: Once = Once::new(); + +/// Returns the fraction of its total verification gas limit an operation used when simulated, +/// or `None` if that limit is 0. +/// +/// `pre_op_gas` is the simulation's pre-op gas, which includes the pre-verification gas. +pub(crate) fn verification_gas_efficiency( + uo: &UserOperationVariant, + pre_op_gas: u128, +) -> Option { + efficiency( + pre_op_gas.saturating_sub(uo.pre_verification_gas()), + uo.total_verification_gas_limit(), + ) +} + +/// Records how much of its verification gas limit an operation used when simulated for the pool. +/// +/// `efficiency` is from [`verification_gas_efficiency`]. It is recorded scaled like the pool's +/// reject threshold, so an op is rejected when the recorded value is below the configured one. +pub(crate) fn record_admission_verification_gas_efficiency( + entry_point: Address, + uo: &UserOperationVariant, + efficiency: f64, +) { + // `new_with_labels` does not register the metric description + DESCRIBE_ADMISSION.call_once(AdmissionGasMetrics::describe); + + AdmissionGasMetrics::new_with_labels(&OpGasLabels::from_op(uo).to_labels(entry_point)) + .admission_verification_gas_efficiency + .record(efficiency / uo.effective_verification_gas_limit_efficiency_reject_threshold(1.0)); +} + #[cfg(test)] mod tests { use alloy_primitives::{Bytes, address}; @@ -275,4 +320,89 @@ mod tests { // 175_000 used of 350_000 assert_eq!(value, &DebugValue::Histogram(vec![0.5.into()])); } + + #[test] + fn test_record_admission_verification_gas_efficiency() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let chain_spec = ChainSpec::default(); + let op: UserOperationVariant = v0_7_builder(&chain_spec, U256::ZERO) + .paymaster(PAYMASTER, 50_000, 0, Bytes::new()) + .build() + .into(); + + // 50_000 pre-verification gas + 125_000 verification gas used + let efficiency = verification_gas_efficiency(&op, 175_000).unwrap(); + metrics::with_local_recorder(&recorder, || { + record_admission_verification_gas_efficiency(ENTRY_POINT, &op, efficiency); + }); + + let snapshot = snapshotter.snapshot().into_vec(); + assert_eq!(snapshot.len(), 1); + let (key, _, _, value) = &snapshot[0]; + let key = key.key(); + assert_eq!(key.name(), GAS_EFFICIENCY_HISTOGRAMS[1]); + + let mut labels = key + .labels() + .map(|label| format!("{}={}", label.key(), label.value())) + .collect::>(); + labels.sort(); + assert_eq!( + labels, + vec![ + format!("entry_point={ENTRY_POINT}"), + "fresh_nonce_slot=true".to_string(), + "has_7702_auth=false".to_string(), + "has_factory=false".to_string(), + "has_paymaster=true".to_string(), + ] + ); + // 125_000 used of 200_000 + 50_000 + assert_eq!(value, &DebugValue::Histogram(vec![0.5.into()])); + } + + #[test] + fn test_verification_gas_efficiency() { + // 150_000 used of 200_000 + assert_eq!( + verification_gas_efficiency(&v0_6_op(false), 200_000), + Some(0.75) + ); + // 150_000 used of 2 * 200_000 + assert_eq!( + verification_gas_efficiency(&v0_6_op(true), 200_000), + Some(0.375) + ); + // pre-op gas below pre-verification gas + assert_eq!( + verification_gas_efficiency(&v0_6_op(false), 10_000), + Some(0.0) + ); + + let chain_spec = ChainSpec::default(); + let no_limit: UserOperationVariant = + v0_6::UserOperationBuilder::new(&chain_spec, Default::default()) + .build() + .into(); + assert_eq!(verification_gas_efficiency(&no_limit, 100_000), None); + } + + #[test] + fn test_record_admission_verification_gas_efficiency_v0_6_paymaster() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let op = v0_6_op(true); + + // 150_000 used of 2 * 200_000 + let efficiency = verification_gas_efficiency(&op, 200_000).unwrap(); + metrics::with_local_recorder(&recorder, || { + record_admission_verification_gas_efficiency(ENTRY_POINT, &op, efficiency); + }); + + let snapshot = snapshotter.snapshot().into_vec(); + assert_eq!(snapshot.len(), 1); + // scaled like the halved reject threshold, 150_000 used of 200_000 + assert_eq!(snapshot[0].3, DebugValue::Histogram(vec![0.75.into()])); + } } diff --git a/crates/pool/src/mempool/uo_pool.rs b/crates/pool/src/mempool/uo_pool.rs index 691af4543..6b410e9e0 100644 --- a/crates/pool/src/mempool/uo_pool.rs +++ b/crates/pool/src/mempool/uo_pool.rs @@ -695,19 +695,23 @@ where .pool .check_associated_storage(&sim_result.associated_addresses, &op)?; - // Check pre op gas limit efficiency + let verification_gas_efficiency = + gas_metrics::verification_gas_efficiency(&op, sim_result.pre_op_gas); + if let Some(verification_gas_efficiency) = verification_gas_efficiency { + gas_metrics::record_admission_verification_gas_efficiency( + self.config.entry_point, + &op, + verification_gas_efficiency, + ); + } + + // Check pre op gas limit efficiency, an op without a verification gas limit is not checked if self .config .verification_gas_limit_efficiency_reject_threshold > 0.0 + && let Some(verification_gas_efficiency) = verification_gas_efficiency { - let verification_gas_used = sim_result - .pre_op_gas - .saturating_sub(op.pre_verification_gas()); - - let verification_gas_efficiency = - verification_gas_used as f64 / op.total_verification_gas_limit() as f64; - let effective_verification_gas_limit_efficiency_reject_threshold = op .effective_verification_gas_limit_efficiency_reject_threshold( self.config @@ -2293,6 +2297,62 @@ mod tests { } } + #[tokio::test] + async fn test_admission_verification_gas_efficiency() { + // recorded whether or not the efficiency threshold then rejects the op + for (threshold, accepted) in [(0.0, true), (0.25, false)] { + let mut config = default_config(); + config.verification_gas_limit_efficiency_reject_threshold = threshold; + + let op = create_op_from_op_v0_6(UserOperationRequiredFields { + call_gas_limit: 10_000, + verification_gas_limit: 500_000, + pre_verification_gas: 50_000, + max_fee_per_gas: 1, + max_priority_fee_per_gas: 1, + ..Default::default() + }); + + let mut ep = MockEntryPointV0_6::new(); + ep.expect_simulate_handle_op().returning(|_, _, _, _, _| { + Ok(Ok(ExecutionResult { + pre_op_gas: 100_000, + paid: uint!(110_000_U256), + target_success: true, + ..Default::default() + })) + }); + + let pool = create_pool_with_entry_point_config( + config, + vec![op.clone()], + ep, + MempoolConfig::default(), + ); + + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + let ret = pool + .add_operation(OperationOrigin::Local, op.op, default_perms()) + .await; + assert_eq!(ret.is_ok(), accepted); + + let samples = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter(|(key, _, _, _)| { + key.key().name() == gas_metrics::GAS_EFFICIENCY_HISTOGRAMS[1] + }) + .map(|(_, _, _, value)| value) + .collect::>(); + // the simulator reports 100K pre-op gas: 50K PVG + 50K of 500K verification gas + assert_eq!(samples, vec![DebugValue::Histogram(vec![0.1.into()])]); + } + } + #[tokio::test] async fn test_call_gas_limit_reject() { let mut config = default_config(); From 63816f89222646ef3d4662edba26be3ab8a37e32 Mon Sep 17 00:00:00 2001 From: Ivan Nakov Date: Mon, 28 Sep 2026 12:16:10 +0300 Subject: [PATCH 4/7] feat(builder): record fee and gas compensation of mined bundles The bundler is the handleOps beneficiary, so a mined bundle is paid back the sum of actualGasCost of its UserOperationEvents. Nothing compared that with what the bundle transaction cost. The transaction tracker now keeps the UserOperationEvents from the receipt it already fetches, with their emitter address. The sender keeps only events from the pinned entry point and records: - builder_bundle_fee_paid_gwei and builder_bundle_compensation_gwei counters (entry_point, sender, success), for every mined bundle - builder_bundle_gas_compensation_ratio (sum of actualGasUsed / gas used) and builder_bundle_fee_compensation_ratio (compensation / fee) histograms, labelled by entry_point, bundle_size (1, 2-4, 5-9, 10+) and has_bundler_sponsored_op - builder_bundle_ops and builder_bundle_bundler_sponsored_ops histograms per entry point Histograms are recorded for successful bundles only, since a reverted receipt has no events. The fee is gas used times effective gas price and leaves out the separate L1 data fee on OP-stack chains. --- Cargo.lock | 3 + bin/rundler/src/cli/metrics.rs | 12 + crates/builder/Cargo.toml | 3 + crates/builder/src/bundle_metrics.rs | 347 ++++++++++++++++++++++ crates/builder/src/bundle_sender.rs | 17 ++ crates/builder/src/lib.rs | 7 + crates/builder/src/transaction_tracker.rs | 163 +++++++++- 7 files changed, 548 insertions(+), 4 deletions(-) create mode 100644 crates/builder/src/bundle_metrics.rs diff --git a/Cargo.lock b/Cargo.lock index f87a70e2b..3a72f57ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5501,6 +5501,7 @@ dependencies = [ "alloy-rpc-types-eth", "alloy-signer", "alloy-signer-local", + "alloy-sol-types", "alloy-transport", "anyhow", "async-trait", @@ -5511,10 +5512,12 @@ dependencies = [ "linked-hash-map", "metrics", "metrics-derive", + "metrics-util", "mockall", "prost 0.13.4", "rand 0.8.5", "reqwest 0.12.9", + "rundler-contracts", "rundler-provider", "rundler-signer", "rundler-sim", diff --git a/bin/rundler/src/cli/metrics.rs b/bin/rundler/src/cli/metrics.rs index 0aba2448e..f5dd886bb 100644 --- a/bin/rundler/src/cli/metrics.rs +++ b/bin/rundler/src/cli/metrics.rs @@ -46,6 +46,18 @@ pub fn initialize<'a, T: TaskSpawner>( rundler_pool::GAS_EFFICIENCY_BUCKETS, )?; } + for name in rundler_builder::BUNDLE_RATIO_HISTOGRAMS { + builder = builder.set_buckets_for_metric( + Matcher::Full(format!("rundler.{name}")), + rundler_builder::BUNDLE_RATIO_BUCKETS, + )?; + } + for name in rundler_builder::BUNDLE_OP_COUNT_HISTOGRAMS { + builder = builder.set_buckets_for_metric( + Matcher::Full(format!("rundler.{name}")), + rundler_builder::BUNDLE_OP_COUNT_BUCKETS, + )?; + } let (recorder, exporter) = builder.build()?; task_spawner.spawn_critical( diff --git a/crates/builder/Cargo.toml b/crates/builder/Cargo.toml index 055f015ee..48aca02c5 100644 --- a/crates/builder/Cargo.toml +++ b/crates/builder/Cargo.toml @@ -15,6 +15,7 @@ alloy-network.workspace = true alloy-primitives.workspace = true alloy-signer.workspace = true alloy-signer-local.workspace = true +alloy-sol-types.workspace = true anyhow.workspace = true async-trait.workspace = true @@ -28,6 +29,7 @@ metrics-derive.workspace = true prost.workspace = true rand.workspace = true reqwest = { workspace = true, default-features = false, features = ["json"] } +rundler-contracts.workspace = true rundler-provider.workspace = true rundler-signer.workspace = true rundler-sim.workspace = true @@ -49,6 +51,7 @@ tracing.workspace = true alloy-json-rpc.workspace = true alloy-rpc-types-eth.workspace = true alloy-transport.workspace = true +metrics-util = { version = "0.19.0", default-features = false, features = ["debugging"] } mockall.workspace = true rundler-provider = { workspace = true, features = ["test-utils"] } rundler-sim = { workspace = true, features = ["test-utils"] } diff --git a/crates/builder/src/bundle_metrics.rs b/crates/builder/src/bundle_metrics.rs new file mode 100644 index 000000000..79bc1633b --- /dev/null +++ b/crates/builder/src/bundle_metrics.rs @@ -0,0 +1,347 @@ +// This file is part of Rundler. +// +// Rundler is free software: you can redistribute it and/or modify it under the +// terms of the GNU Lesser General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later version. +// +// Rundler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with Rundler. +// If not, see https://www.gnu.org/licenses/. + +//! Metrics comparing what a mined bundle cost the bundler with what the entry point paid back. + +use std::sync::Once; + +use alloy_primitives::{Address, U256}; +use metrics::{Counter, Histogram}; +use metrics_derive::Metrics; + +use crate::transaction_tracker::MinedUserOpEvent; + +/// Names of the bundle compensation ratio histograms, without the global metrics prefix. +pub const BUNDLE_RATIO_HISTOGRAMS: &[&str] = &[ + "builder.bundle_gas_compensation_ratio", + "builder.bundle_fee_compensation_ratio", +]; + +/// Histogram buckets for the compensation ratios. Values above 1 are expected for larger +/// bundles, since each op's pre-verification gas assumes a bundle of one. +pub const BUNDLE_RATIO_BUCKETS: &[f64] = &[ + 0.0, 0.5, 0.8, 0.9, 0.95, 1.0, 1.05, 1.1, 1.25, 1.5, 2.0, 3.0, 5.0, +]; + +/// Names of the op count per bundle histograms, without the global metrics prefix. +pub const BUNDLE_OP_COUNT_HISTOGRAMS: &[&str] = + &["builder.bundle_ops", "builder.bundle_bundler_sponsored_ops"]; + +/// Histogram buckets for the number of ops in a bundle. +pub const BUNDLE_OP_COUNT_BUCKETS: &[f64] = + &[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 10.0, 20.0, 50.0, 100.0, 128.0]; + +const WEI_PER_GWEI: U256 = U256::from_limbs([1_000_000_000, 0, 0, 0]); + +#[derive(Metrics)] +#[metrics(scope = "builder")] +struct BundleCompensationCounters { + #[metric( + describe = "the fee paid for mined bundle transactions in gwei (gas used times effective gas price)." + )] + bundle_fee_paid_gwei: Counter, + #[metric( + describe = "the compensation paid to the bundler by the entry point for mined bundles in gwei (sum of actualGasCost)." + )] + bundle_compensation_gwei: Counter, +} + +#[derive(Metrics)] +#[metrics(scope = "builder")] +struct BundleCompensationRatios { + #[metric( + describe = "the gas the entry point charged ops for divided by the gas the bundle transaction used, for successful bundles." + )] + bundle_gas_compensation_ratio: Histogram, + #[metric( + describe = "the compensation paid to the bundler divided by the bundle transaction fee, for successful bundles." + )] + bundle_fee_compensation_ratio: Histogram, +} + +#[derive(Metrics)] +#[metrics(scope = "builder")] +struct BundleOpCounts { + #[metric(describe = "the number of user operations in a successful mined bundle.")] + bundle_ops: Histogram, + #[metric( + describe = "the number of bundler sponsored user operations (actualGasCost of 0) in a successful mined bundle." + )] + bundle_bundler_sponsored_ops: Histogram, +} + +static DESCRIBE: Once = Once::new(); + +fn bundle_size_label(ops: usize) -> &'static str { + match ops { + 0..=1 => "1", + 2..=4 => "2-4", + 5..=9 => "5-9", + _ => "10+", + } +} + +fn wei_to_gwei(wei: U256) -> u64 { + (wei / WEI_PER_GWEI).saturating_to() +} + +fn ratio(numerator: U256, denominator: U256) -> Option { + if denominator.is_zero() { + return None; + } + Some(numerator.saturating_to::() as f64 / denominator.saturating_to::() as f64) +} + +/// Records what a mined bundle cost the bundler and what the entry point paid back. +/// +/// Only events emitted by `entry_point` are counted. Reverted bundles have no events, so they +/// only update the counters. +pub(crate) fn record_mined_bundle( + entry_point: Address, + sender: Address, + is_success: bool, + gas_used: Option, + gas_price: Option, + events: &[MinedUserOpEvent], +) { + // `new_with_labels` does not register the metric descriptions + DESCRIBE.call_once(|| { + BundleCompensationCounters::describe(); + BundleCompensationRatios::describe(); + BundleOpCounts::describe(); + }); + + let events = events + .iter() + .filter(|event| event.entry_point == entry_point) + .collect::>(); + let compensation = events.iter().fold(U256::ZERO, |sum, event| { + sum.saturating_add(event.actual_gas_cost) + }); + let fee = gas_used + .zip(gas_price) + .map(|(used, price)| U256::from(used) * U256::from(price)); + + let counters = BundleCompensationCounters::new_with_labels(&[ + ("entry_point", entry_point.to_string()), + ("sender", sender.to_string()), + ("success", is_success.to_string()), + ]); + if let Some(fee) = fee { + counters.bundle_fee_paid_gwei.increment(wei_to_gwei(fee)); + } + counters + .bundle_compensation_gwei + .increment(wei_to_gwei(compensation)); + + let Some(gas_used) = gas_used.filter(|used| *used > 0) else { + return; + }; + if !is_success || events.is_empty() { + return; + } + + let sponsored_ops = events + .iter() + .filter(|event| event.actual_gas_cost.is_zero()) + .count(); + let op_counts = BundleOpCounts::new_with_labels(&[("entry_point", entry_point.to_string())]); + op_counts.bundle_ops.record(events.len() as f64); + op_counts + .bundle_bundler_sponsored_ops + .record(sponsored_ops as f64); + + let ratios = BundleCompensationRatios::new_with_labels(&[ + ("entry_point", entry_point.to_string()), + ("bundle_size", bundle_size_label(events.len()).to_string()), + ("has_bundler_sponsored_op", (sponsored_ops > 0).to_string()), + ]); + let charged_gas = events.iter().fold(U256::ZERO, |sum, event| { + sum.saturating_add(event.actual_gas_used) + }); + if let Some(gas_ratio) = ratio(charged_gas, U256::from(gas_used)) { + ratios.bundle_gas_compensation_ratio.record(gas_ratio); + } + if let Some(fee_ratio) = fee.and_then(|fee| ratio(compensation, fee)) { + ratios.bundle_fee_compensation_ratio.record(fee_ratio); + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use alloy_primitives::address; + use metrics_util::debugging::{DebugValue, DebuggingRecorder}; + + use super::*; + + const ENTRY_POINT: Address = address!("0000000071727De22E5E9d8BAf0edAc6f37da032"); + const SENDER: Address = address!("00000000000000000000000000000000000000aa"); + const OTHER: Address = address!("00000000000000000000000000000000000000bb"); + + fn event(entry_point: Address, actual_gas_cost: u64, actual_gas_used: u64) -> MinedUserOpEvent { + MinedUserOpEvent { + entry_point, + actual_gas_cost: U256::from(actual_gas_cost), + actual_gas_used: U256::from(actual_gas_used), + } + } + + /// Records one bundle and returns `"name{sorted labels}" => value` for every metric touched. + fn record( + is_success: bool, + gas_used: Option, + gas_price: Option, + events: &[MinedUserOpEvent], + ) -> BTreeMap { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + metrics::with_local_recorder(&recorder, || { + record_mined_bundle(ENTRY_POINT, SENDER, is_success, gas_used, gas_price, events); + }); + + snapshotter + .snapshot() + .into_vec() + .into_iter() + .map(|(key, _, _, value)| { + let key = key.key(); + let mut labels = key + .labels() + .filter(|label| !matches!(label.key(), "entry_point" | "sender")) + .map(|label| format!("{}={}", label.key(), label.value())) + .collect::>(); + labels.sort(); + (format!("{}{{{}}}", key.name(), labels.join(",")), value) + }) + .collect() + } + + #[test] + fn test_bundle_size_label() { + assert_eq!(bundle_size_label(1), "1"); + assert_eq!(bundle_size_label(2), "2-4"); + assert_eq!(bundle_size_label(4), "2-4"); + assert_eq!(bundle_size_label(5), "5-9"); + assert_eq!(bundle_size_label(9), "5-9"); + assert_eq!(bundle_size_label(10), "10+"); + } + + #[test] + fn test_wei_to_gwei() { + assert_eq!(wei_to_gwei(U256::from(1_999_999_999_u64)), 1); + assert_eq!(wei_to_gwei(U256::MAX), u64::MAX); + } + + #[test] + fn test_successful_bundle() { + let metrics = record( + true, + Some(300_000), + Some(10_000_000_000), + &[ + event(ENTRY_POINT, 1_500_000_000_000_000, 150_000), + event(ENTRY_POINT, 1_800_000_000_000_000, 180_000), + event(ENTRY_POINT, 0, 30_000), + // not our entry point, e.g. an account emitting a look-alike event + event(OTHER, 1_000_000_000_000_000_000, 1_000_000), + ], + ); + + let ratio_labels = "{bundle_size=2-4,has_bundler_sponsored_op=true}"; + assert_eq!( + metrics, + BTreeMap::from([ + ( + "builder.bundle_fee_paid_gwei{success=true}".to_string(), + // 300_000 gas * 10 gwei + DebugValue::Counter(3_000_000) + ), + ( + "builder.bundle_compensation_gwei{success=true}".to_string(), + DebugValue::Counter(3_300_000) + ), + ( + "builder.bundle_ops{}".to_string(), + DebugValue::Histogram(vec![3.0.into()]) + ), + ( + "builder.bundle_bundler_sponsored_ops{}".to_string(), + DebugValue::Histogram(vec![1.0.into()]) + ), + ( + format!("{}{ratio_labels}", BUNDLE_RATIO_HISTOGRAMS[0]), + // 360_000 charged / 300_000 used + DebugValue::Histogram(vec![1.2.into()]) + ), + ( + format!("{}{ratio_labels}", BUNDLE_RATIO_HISTOGRAMS[1]), + // 3_300_000 gwei paid back / 3_000_000 gwei fee + DebugValue::Histogram(vec![1.1.into()]) + ), + ]) + ); + assert!(BUNDLE_OP_COUNT_HISTOGRAMS.iter().all(|name| { + metrics + .keys() + .any(|key| key.starts_with(&format!("{name}{{"))) + })); + } + + #[test] + fn test_reverted_bundle_only_updates_counters() { + let metrics = record(false, Some(100_000), Some(1_000_000_000), &[]); + + assert_eq!( + metrics, + BTreeMap::from([ + ( + "builder.bundle_fee_paid_gwei{success=false}".to_string(), + DebugValue::Counter(100_000) + ), + ( + "builder.bundle_compensation_gwei{success=false}".to_string(), + DebugValue::Counter(0) + ), + ]) + ); + } + + #[test] + fn test_unknown_or_zero_fee_skips_fee_ratio() { + for gas_price in [None, Some(0)] { + let metrics = record( + true, + Some(100_000), + gas_price, + &[event(ENTRY_POINT, 0, 90_000)], + ); + + let ratio_labels = "{bundle_size=1,has_bundler_sponsored_op=true}"; + assert_eq!( + metrics.get(&format!("{}{ratio_labels}", BUNDLE_RATIO_HISTOGRAMS[0])), + Some(&DebugValue::Histogram(vec![0.9.into()])) + ); + // registered alongside the gas ratio, but no fee ratio sample is recorded + assert_eq!( + metrics.get(&format!("{}{ratio_labels}", BUNDLE_RATIO_HISTOGRAMS[1])), + Some(&DebugValue::Histogram(vec![])) + ); + assert_eq!( + metrics.get("builder.bundle_fee_paid_gwei{success=true}"), + Some(&DebugValue::Counter(0)) + ); + } + } +} diff --git a/crates/builder/src/bundle_sender.rs b/crates/builder/src/bundle_sender.rs index 53ef9a424..d3f1827fd 100644 --- a/crates/builder/src/bundle_sender.rs +++ b/crates/builder/src/bundle_sender.rs @@ -42,6 +42,7 @@ use tracing::{debug, error, info, warn}; use crate::{ ProposerKey, assigner::{Assigner, AssignmentResult}, + bundle_metrics, bundle_proposer::{BundleData, BundleProposalRequest, BundleProposerError, BundleProposerT}, emit::{BuilderEvent, BundleTxDetails}, sender::{ProviderEventSignal, RpcOutcomeClass, TxSenderError}, @@ -621,9 +622,11 @@ where attempt_number, gas_limit, gas_used, + gas_price, tx_hash, nonce, is_success, + user_op_events, .. } => { info!( @@ -641,6 +644,16 @@ where if let Some(used) = gas_used { self.increment_counter("builder_bundle_gas_used", &pinned, used); } + if let Some((entry_point, _)) = &pinned { + bundle_metrics::record_mined_bundle( + *entry_point, + self.sender_eoa, + is_success, + gas_used, + gas_price, + &user_op_events, + ); + } if !is_success && let Err(e) = self.process_revert(tx_hash, &pinned).await { warn!( @@ -2285,6 +2298,7 @@ mod tests { tx_hash: B256::ZERO, attempt_number: 0, is_success: true, + user_op_events: vec![], })) }) }); @@ -2734,6 +2748,7 @@ mod tests { tx_hash: B256::ZERO, attempt_number: 0, is_success: true, + user_op_events: vec![], }); sender_impl .step_after_trigger(&mut state, update) @@ -2947,6 +2962,7 @@ mod tests { tx_hash: B256::ZERO, attempt_number: 0, is_success: false, // revert + user_op_events: vec![], })) }) }); @@ -3410,6 +3426,7 @@ mod tests { tx_hash: B256::ZERO, attempt_number: 0, is_success: false, // revert + user_op_events: vec![], })) }) }); diff --git a/crates/builder/src/lib.rs b/crates/builder/src/lib.rs index d83366399..c20840960 100644 --- a/crates/builder/src/lib.rs +++ b/crates/builder/src/lib.rs @@ -22,6 +22,13 @@ use alloy_primitives::Address; mod assigner; + +mod bundle_metrics; +pub use bundle_metrics::{ + BUNDLE_OP_COUNT_BUCKETS, BUNDLE_OP_COUNT_HISTOGRAMS, BUNDLE_RATIO_BUCKETS, + BUNDLE_RATIO_HISTOGRAMS, +}; + mod bundle_proposer; mod bundle_sender; mod delegation_sender; diff --git a/crates/builder/src/transaction_tracker.rs b/crates/builder/src/transaction_tracker.rs index 8ad870117..709f4f345 100644 --- a/crates/builder/src/transaction_tracker.rs +++ b/crates/builder/src/transaction_tracker.rs @@ -15,13 +15,15 @@ use std::sync::Arc; use alloy_consensus::Transaction; use alloy_primitives::{Address, B256, I256, U256}; +use alloy_sol_types::SolEvent; use anyhow::bail; use async_trait::async_trait; use metrics::{Gauge, Histogram}; use metrics_derive::Metrics; #[cfg(test)] use mockall::automock; -use rundler_provider::{EvmProvider, ReceiptResponse, TransactionRequest}; +use rundler_contracts::v0_7::IEntryPoint::UserOperationEvent; +use rundler_provider::{EvmProvider, Log, ReceiptResponse, TransactionRequest}; use rundler_signer::{SignerLease, SignerManager}; use rundler_types::{ExpectedStorage, GasFees, pool::AddressUpdate}; use tokio::time::Instant; @@ -127,6 +129,7 @@ pub(crate) enum TrackerUpdate { gas_used: Option, gas_price: Option, is_success: bool, + user_op_events: Vec, }, LatestTxDropped { nonce: u64, @@ -136,6 +139,33 @@ pub(crate) enum TrackerUpdate { }, } +/// A `UserOperationEvent` from a mined transaction's receipt. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct MinedUserOpEvent { + /// The address that emitted the log, only an entry point if it matches one + pub(crate) entry_point: Address, + pub(crate) actual_gas_cost: U256, + pub(crate) actual_gas_used: U256, +} + +impl MinedUserOpEvent { + /// Decodes a `UserOperationEvent` log, returning `None` for any other log. + /// + /// The event has the same signature and layout in every entry point version. + fn from_log(log: &Log) -> Option { + if log.topic0() != Some(&UserOperationEvent::SIGNATURE_HASH) { + return None; + } + let decoded = log.log_decode::().ok()?; + let event = decoded.data(); + Some(Self { + entry_point: log.address(), + actual_gas_cost: event.actualGasCost, + actual_gas_used: event.actualGasUsed, + }) + } +} + pub(crate) struct TransactionTrackerImpl { provider: P, sender: T, @@ -282,6 +312,13 @@ where gas_used: Some(r.inner.gas_used()), gas_price: Some(r.effective_gas_price), is_success: r.inner.status(), + user_op_events: r + .inner + .inner + .logs() + .iter() + .filter_map(MinedUserOpEvent::from_log) + .collect(), })), None => { warn!("failed to find transaction receipt for tx: {}", tx_hash); @@ -298,6 +335,7 @@ struct MinedTxInfo { gas_used: Option, gas_price: Option, is_success: bool, + user_op_events: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -567,6 +605,7 @@ where gas_used: mined_tx_info.gas_used, gas_price: mined_tx_info.gas_price, is_success: mined_tx_info.is_success, + user_op_events: mined_tx_info.user_op_events, }; if let Some(sent_at_time) = tx.sent_at_time { @@ -698,9 +737,9 @@ mod tests { atomic::{AtomicBool, Ordering}, }; - use alloy_consensus::{Signed, TxEip1559, transaction::Recovered}; + use alloy_consensus::{Receipt, Signed, TxEip1559, transaction::Recovered}; use alloy_network::TxSigner; - use alloy_primitives::{Address, Signature, U256}; + use alloy_primitives::{Address, Bytes, Log as PrimitiveLog, LogData, Signature, U256}; use alloy_rpc_types_eth::{ Transaction as AlloyTransaction, TransactionReceipt as AlloyTransactionReceipt, }; @@ -1096,6 +1135,119 @@ mod tests { }) .into() } + #[tokio::test] + async fn test_process_update_mined_keeps_user_op_events() { + let (mut sender, mut provider, signer) = create_base_config(0); + + let tx_hash = B256::random(); + let entry_point = Address::random(); + let other = Address::random(); + + sender + .expect_send_transaction() + .returning(move |_a, _b, _c| Box::pin(async move { Ok(tx_hash) })); + + provider + .expect_get_transaction_by_hash() + .returning(|hash: B256| Ok(Some(sign_transaction(hash)))); + + provider + .expect_get_transaction_receipt() + .returning(move |_: B256| { + let user_op_log = + |address: Address, actual_gas_cost: u64, actual_gas_used: u64| Log { + inner: PrimitiveLog { + address, + data: UserOperationEvent { + userOpHash: B256::random(), + sender: Address::random(), + paymaster: Address::ZERO, + nonce: U256::ZERO, + success: true, + actualGasCost: U256::from(actual_gas_cost), + actualGasUsed: U256::from(actual_gas_used), + } + .encode_log_data(), + }, + ..Default::default() + }; + // a log that is not a UserOperationEvent + let other_log = Log { + inner: PrimitiveLog { + address: entry_point, + data: LogData::new_unchecked(vec![B256::random()], Bytes::new()), + }, + ..Default::default() + }; + + Ok(Some(WithOtherFields::new(AlloyTransactionReceipt { + inner: AnyReceiptEnvelope { + inner: ReceiptWithBloom { + receipt: Receipt { + status: true.into(), + cumulative_gas_used: 0, + logs: vec![ + user_op_log(entry_point, 1_000, 100), + other_log, + user_op_log(other, 2_000, 200), + ], + }, + logs_bloom: Default::default(), + }, + r#type: 0, + }, + transaction_hash: B256::ZERO, + transaction_index: None, + block_hash: None, + block_number: None, + gas_used: 0, + effective_gas_price: 0, + blob_gas_used: None, + blob_gas_price: None, + from: Address::ZERO, + to: None, + contract_address: None, + }))) + }); + + let mut tracker = create_tracker(sender, provider, signer).await; + + let tx = TransactionRequest::default().nonce(0); + let exp = ExpectedStorage::default(); + + // send dummy transaction + let _sent = tracker.send_transaction(tx, &exp, 0).await; + let update = AddressUpdate { + address: Address::ZERO, + nonce: Some(1), + mined_tx_hashes: vec![tx_hash], + balance: U256::from(1000), + }; + + let Some(TrackerUpdate::Mined { user_op_events, .. }) = + tracker.process_update(&update).await.unwrap() + else { + panic!("expected a mined update"); + }; + + // events are kept with their emitter, filtering by entry point happens in the sender + assert_eq!( + user_op_events, + vec![ + MinedUserOpEvent { + entry_point, + actual_gas_cost: U256::from(1_000), + actual_gas_used: U256::from(100), + }, + MinedUserOpEvent { + entry_point: other, + actual_gas_cost: U256::from(2_000), + actual_gas_used: U256::from(200), + }, + ] + ); + } + #[tokio::test] async fn test_process_update_mined() { let (mut sender, mut provider, signer) = create_base_config(0); @@ -1148,7 +1300,10 @@ mod tests { let tracker_update = tracker.process_update(&update).await.unwrap().unwrap(); - assert!(matches!(tracker_update, TrackerUpdate::Mined { .. })); + assert!(matches!( + tracker_update, + TrackerUpdate::Mined { ref user_op_events, .. } if user_op_events.is_empty() + )); let state = tracker.get_state().unwrap(); assert_eq!( From 4f29ab7f82d26561c13d2a3f290bfdd8fa433a35 Mon Sep 17 00:00:00 2001 From: Ivan Nakov Date: Mon, 28 Sep 2026 12:59:25 +0300 Subject: [PATCH 5/7] feat(sim,rpc): record gas estimation search effort, clamps and errors Gas estimation only had timing histograms. Add: - gas_estimator_eth_calls{entry_point, field, outcome}: eth_calls a binary search used, recorded on every exit and counting a call that failed. outcome is success, revert, error, or not_converged when the search used all max_gas_estimation_rounds, which today only surfaces as GasEstimationError::Other - gas_estimator_clamped_estimates{entry_point, field}: estimates whose buffered value was cut down to max_verification_gas or max_bundle_execution_gas - gas_estimator_errors{entry_point, kind}: eth_estimateUserOperationGas errors by GasEstimationError variant, recorded in the RPC router field is verification, paymaster_verification or call. The search loop moves into binary_search, wrapped by run_binary_search, which records the metric and returns the same estimates and errors as before. --- Cargo.lock | 1 + bin/rundler/src/cli/metrics.rs | 6 + crates/rpc/src/eth/router.rs | 3 + crates/sim/Cargo.toml | 1 + .../sim/src/estimation/estimate_call_gas.rs | 2 + .../estimation/estimate_verification_gas.rs | 10 + crates/sim/src/estimation/mod.rs | 397 +++++++++++++++++- crates/sim/src/estimation/v0_6.rs | 234 ++++++++++- crates/sim/src/estimation/v0_7.rs | 48 ++- crates/sim/src/lib.rs | 7 +- 10 files changed, 683 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3a72f57ae..dfb061bd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5732,6 +5732,7 @@ dependencies = [ "futures-util", "metrics", "metrics-derive", + "metrics-util", "mockall", "rundler-contracts", "rundler-provider", diff --git a/bin/rundler/src/cli/metrics.rs b/bin/rundler/src/cli/metrics.rs index f5dd886bb..befe78a6b 100644 --- a/bin/rundler/src/cli/metrics.rs +++ b/bin/rundler/src/cli/metrics.rs @@ -58,6 +58,12 @@ pub fn initialize<'a, T: TaskSpawner>( rundler_builder::BUNDLE_OP_COUNT_BUCKETS, )?; } + for name in rundler_sim::ESTIMATION_ETH_CALL_HISTOGRAMS { + builder = builder.set_buckets_for_metric( + Matcher::Full(format!("rundler.{name}")), + rundler_sim::ESTIMATION_ETH_CALL_BUCKETS, + )?; + } let (recorder, exporter) = builder.build()?; task_spawner.spawn_critical( diff --git a/crates/rpc/src/eth/router.rs b/crates/rpc/src/eth/router.rs index bcdefd553..e418badf4 100644 --- a/crates/rpc/src/eth/router.rs +++ b/crates/rpc/src/eth/router.rs @@ -387,6 +387,9 @@ where .estimate_op_gas(uo.into(), state_override.unwrap_or_default()) .await; + if let Err(error) = &result { + rundler_sim::record_estimation_error(*self.entry_point.address(), error); + } if let Err(GasEstimationError::RevertInValidation(revert)) = &result { entry_point_metrics::record_aa_error( AaErrorStage::Estimation, diff --git a/crates/sim/Cargo.toml b/crates/sim/Cargo.toml index 877f53b78..ffdcbcad9 100644 --- a/crates/sim/Cargo.toml +++ b/crates/sim/Cargo.toml @@ -36,6 +36,7 @@ alloy-primitives = { workspace = true, features = ["rand"] } alloy-signer.workspace = true alloy-signer-local.workspace = true alloy-transport.workspace = true +metrics-util = { version = "0.19.0", default-features = false, features = ["debugging"] } mockall.workspace = true rundler-provider = { workspace = true, features = ["test-utils"] } rundler-types = { workspace = true, features = ["test-utils"] } diff --git a/crates/sim/src/estimation/estimate_call_gas.rs b/crates/sim/src/estimation/estimate_call_gas.rs index 2c78a937a..ef0f91e96 100644 --- a/crates/sim/src/estimation/estimate_call_gas.rs +++ b/crates/sim/src/estimation/estimate_call_gas.rs @@ -140,6 +140,8 @@ where round_fn, self.settings.max_bundle_execution_gas, self.settings.max_gas_estimation_rounds, + *self.entry_point.address(), + "call", ) .await?; diff --git a/crates/sim/src/estimation/estimate_verification_gas.rs b/crates/sim/src/estimation/estimate_verification_gas.rs index 74d7e48b4..5fe2443ec 100644 --- a/crates/sim/src/estimation/estimate_verification_gas.rs +++ b/crates/sim/src/estimation/estimate_verification_gas.rs @@ -54,6 +54,14 @@ pub trait VerificationGasEstimatorSpecialization: Send + Sync { fn get_call(&self, op: Self::UO, args: &EstimateGasArgs) -> Bytes; fn decode_revert(&self, revert_data: &Bytes) -> GasEstimationError; + + /// The address of the entry point being estimated for, used as a metrics label + fn entry_point(&self) -> Address; + + /// The gas field this specialization estimates, used as a metrics label + fn estimated_field(&self) -> &'static str { + "verification" + } } #[derive(Debug, Clone)] @@ -116,6 +124,8 @@ where round_fn, self.settings.max_verification_gas, self.settings.max_gas_estimation_rounds, + self.specialization.entry_point(), + self.specialization.estimated_field(), ) .await?; diff --git a/crates/sim/src/estimation/mod.rs b/crates/sim/src/estimation/mod.rs index eccd23148..5fee3b1a3 100644 --- a/crates/sim/src/estimation/mod.rs +++ b/crates/sim/src/estimation/mod.rs @@ -11,12 +11,12 @@ // You should have received a copy of the GNU General Public License along with Rundler. // If not, see https://www.gnu.org/licenses/. -use std::{future::Future, pin::Pin}; +use std::{future::Future, pin::Pin, sync::Once}; use alloy_primitives::{Address, Bytes}; use alloy_sol_types::SolInterface; use anyhow::{Context, anyhow}; -use metrics::Histogram; +use metrics::{Counter, Histogram}; use metrics_derive::Metrics; #[cfg(feature = "test-utils")] use mockall::automock; @@ -82,6 +82,23 @@ pub enum GasEstimationError { Other(#[from] anyhow::Error), } +impl GasEstimationError { + /// Returns a bounded label value for the error variant + pub fn kind(&self) -> &'static str { + match self { + Self::RevertInValidation(_) => "revert_in_validation", + Self::RevertInCallWithMessage(_) | Self::RevertInCallWithBytes(_) => "revert_in_call", + Self::CallGasLimitTooLow(_) => "call_gas_limit_too_low", + Self::GasUsedTooLarge => "gas_used_too_large", + Self::GasFieldTooLarge(_, _) => "gas_field_too_large", + Self::GasTotalTooLarge(_, _) => "gas_total_too_large", + Self::UnsupportedAggregator(_) => "unsupported_aggregator", + Self::ProviderError(_) => "provider", + Self::Other(_) => "other", + } + } +} + /// Gas estimator trait #[cfg_attr(feature = "test-utils", automock(type UserOperationOptionalGas = rundler_types::v0_6::UserOperationOptionalGas;))] #[async_trait::async_trait] @@ -155,6 +172,103 @@ struct Metrics { pvgl_estimate_ms: Histogram, } +/// Names of the estimation eth_call histograms, without the global metrics prefix. +pub const ESTIMATION_ETH_CALL_HISTOGRAMS: &[&str] = &["gas_estimator.eth_calls"]; + +/// Histogram buckets for the number of eth_calls a binary search used. +pub const ESTIMATION_ETH_CALL_BUCKETS: &[f64] = &[1.0, 2.0, 3.0, 4.0, 5.0, 10.0]; + +// Separate structs because the metrics have different labels, and `new_with_labels` +// registers every field of a struct +#[derive(Metrics)] +#[metrics(scope = "gas_estimator")] +struct SearchCallMetrics { + #[metric( + describe = "the number of eth_calls a gas estimation binary search used, by entry point, field and outcome (success, revert, error, or not_converged when all max_gas_estimation_rounds were used), including the call that failed." + )] + eth_calls: Histogram, +} + +#[derive(Metrics)] +#[metrics(scope = "gas_estimator")] +struct ClampMetrics { + #[metric( + describe = "the count of estimates where the buffered value was cut down to its maximum, by entry point and field." + )] + clamped_estimates: Counter, +} + +#[derive(Metrics)] +#[metrics(scope = "gas_estimator")] +struct ErrorMetrics { + #[metric(describe = "the count of gas estimation errors, by entry point and error kind.")] + errors: Counter, +} + +static DESCRIBE: Once = Once::new(); + +fn describe_metrics() { + // `new_with_labels` does not register the metric descriptions + DESCRIBE.call_once(|| { + SearchCallMetrics::describe(); + ClampMetrics::describe(); + ErrorMetrics::describe(); + }); +} + +/// Records the eth_calls of one binary search, labelled by how it ended. +fn record_search( + entry_point: Address, + field: &'static str, + eth_calls: u32, + result: &Result, GasEstimationError>, +) { + describe_metrics(); + let outcome = match result { + Ok(Some(BinarySearchResult::Success(..))) => "success", + Ok(Some(BinarySearchResult::Revert(_))) => "revert", + Ok(None) => "not_converged", + Err(_) => "error", + }; + SearchCallMetrics::new_with_labels(&[ + ("entry_point", entry_point.to_string()), + ("field", field.to_string()), + ("outcome", outcome.to_string()), + ]) + .eth_calls + .record(f64::from(eth_calls)); +} + +/// Counts one gas estimation error by its kind. +pub fn record_estimation_error(entry_point: Address, error: &GasEstimationError) { + describe_metrics(); + ErrorMetrics::new_with_labels(&[ + ("entry_point", entry_point.to_string()), + ("kind", error.kind().to_string()), + ]) + .errors + .increment(1); +} + +/// Counts an estimate whose buffered value is above its cap, so the cap sets the returned limit. +pub(crate) fn record_clamped_estimate( + entry_point: Address, + field: &'static str, + estimate: u128, + cap: u128, +) { + if estimate <= cap { + return; + } + describe_metrics(); + ClampMetrics::new_with_labels(&[ + ("entry_point", entry_point.to_string()), + ("field", field.to_string()), + ]) + .clamped_estimates + .increment(1); +} + enum BinarySearchResult { Success(u128, u32), Revert(Bytes), @@ -164,7 +278,38 @@ async fn run_binary_search( round_fn: F, max_gas: u128, max_rounds: u32, + entry_point: Address, + field: &'static str, ) -> Result +where + F: Fn( + u128, // min gas + u128, // max gas + bool, // is continuation + ) -> Pin> + Send>>, +{ + let mut eth_calls = 0_u32; + let result = binary_search(round_fn, max_gas, max_rounds, &mut eth_calls).await; + + record_search(entry_point, field, eth_calls, &result); + match result { + Ok(Some(result)) => Ok(result), + Ok(None) => Err(anyhow!( + "gas estimation failed to converge after {max_rounds} rounds" + ))?, + Err(error) => Err(error), + } +} + +/// Runs the binary search, returning `None` if it did not converge within `max_rounds`. +/// +/// `eth_calls` counts the rounds started, including one that fails. +async fn binary_search( + round_fn: F, + max_gas: u128, + max_rounds: u32, + eth_calls: &mut u32, +) -> Result, GasEstimationError> where F: Fn( u128, // min gas @@ -178,6 +323,7 @@ where let mut is_continuation = false; for _ in 0..max_rounds { + *eth_calls += 1; let revert_data = round_fn(min_gas, max_gas, is_continuation).await?; let decoded = @@ -190,16 +336,16 @@ where .context("num rounds return overflow")?; num_rounds += ret_num_rounds; - return Ok(BinarySearchResult::Success( + return Ok(Some(BinarySearchResult::Success( result .gas .try_into() .map_err(|_| GasEstimationError::GasUsedTooLarge)?, num_rounds, - )); + ))); } EstimationTypesErrors::EstimateGasRevertAtMax(revert) => { - return Ok(BinarySearchResult::Revert(revert.revertData)); + return Ok(Some(BinarySearchResult::Revert(revert.revertData))); } EstimationTypesErrors::EstimateGasContinuation(continuation) => { let ret_min_gas = continuation @@ -235,7 +381,242 @@ where } } - Err(anyhow!( - "gas estimation failed to converge after {max_rounds} rounds" - ))? + Ok(None) +} + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + use alloy_primitives::{U256, address}; + use alloy_sol_types::SolError; + use metrics_util::debugging::{DebugValue, DebuggingRecorder}; + use rundler_contracts::common::EstimationTypes::{ + EstimateGasContinuation, EstimateGasResult, EstimateGasRevertAtMax, + }; + + use super::*; + + const ENTRY_POINT: Address = address!("0000000071727De22E5E9d8BAf0edAc6f37da032"); + + fn counters(recorder: &DebuggingRecorder) -> Vec<(String, DebugValue)> { + let mut counters = recorder + .snapshotter() + .snapshot() + .into_vec() + .into_iter() + .map(|(key, _, _, value)| { + let key = key.key(); + let mut labels = key + .labels() + .map(|label| format!("{}={}", label.key(), label.value())) + .collect::>(); + labels.sort(); + (format!("{}{{{}}}", key.name(), labels.join(",")), value) + }) + .collect::>(); + counters.sort_by(|a, b| a.0.cmp(&b.0)); + counters + } + + #[test] + fn test_gas_estimation_error_kind() { + let cases = [ + ( + GasEstimationError::RevertInValidation(ValidationRevert::EntryPoint( + "AA26 over verificationGasLimit".to_string(), + )), + "revert_in_validation", + ), + ( + GasEstimationError::RevertInCallWithMessage("reverted".to_string()), + "revert_in_call", + ), + ( + GasEstimationError::RevertInCallWithBytes(Bytes::new()), + "revert_in_call", + ), + ( + GasEstimationError::CallGasLimitTooLow(1), + "call_gas_limit_too_low", + ), + (GasEstimationError::GasUsedTooLarge, "gas_used_too_large"), + ( + GasEstimationError::GasFieldTooLarge("callGasLimit", 1), + "gas_field_too_large", + ), + ( + GasEstimationError::GasTotalTooLarge(2, 1), + "gas_total_too_large", + ), + ( + GasEstimationError::UnsupportedAggregator(Address::ZERO), + "unsupported_aggregator", + ), + ( + GasEstimationError::ProviderError(ProviderError::Other(anyhow!("provider"))), + "provider", + ), + (GasEstimationError::Other(anyhow!("other")), "other"), + ]; + for (error, kind) in cases { + assert_eq!(error.kind(), kind, "{error:?}"); + } + } + + #[test] + fn test_record_estimation_error() { + let recorder = DebuggingRecorder::new(); + metrics::with_local_recorder(&recorder, || { + record_estimation_error(ENTRY_POINT, &GasEstimationError::GasUsedTooLarge); + record_estimation_error(ENTRY_POINT, &GasEstimationError::GasUsedTooLarge); + }); + + assert_eq!( + counters(&recorder), + vec![( + format!( + "gas_estimator.errors{{entry_point={ENTRY_POINT},kind=gas_used_too_large}}" + ), + DebugValue::Counter(2) + )] + ); + } + + #[test] + fn test_record_clamped_estimate() { + let recorder = DebuggingRecorder::new(); + metrics::with_local_recorder(&recorder, || { + // at or below the cap is not clamped + record_clamped_estimate(ENTRY_POINT, "verification", 99, 100); + record_clamped_estimate(ENTRY_POINT, "verification", 100, 100); + record_clamped_estimate(ENTRY_POINT, "verification", 101, 100); + }); + + assert_eq!( + counters(&recorder), + vec![( + format!( + "gas_estimator.clamped_estimates{{entry_point={ENTRY_POINT},field=verification}}" + ), + DebugValue::Counter(1) + )] + ); + } + + fn continuation(min_gas: u64, max_gas: u64) -> Option { + Some( + EstimateGasContinuation { + minGas: U256::from(min_gas), + maxGas: U256::from(max_gas), + numRounds: U256::from(1), + } + .abi_encode() + .into(), + ) + } + + fn converged(gas: u64) -> Option { + Some( + EstimateGasResult { + gas: U256::from(gas), + numRounds: U256::from(1), + } + .abi_encode() + .into(), + ) + } + + /// Runs a search whose rounds return `responses` in order, `None` being a failed eth_call. + async fn search( + responses: Vec>, + max_rounds: u32, + ) -> ( + Result, + Vec<(String, DebugValue)>, + ) { + let responses = Arc::new(responses); + let calls = Arc::new(AtomicUsize::new(0)); + let round_fn = move |_: u128, _: u128, _: bool| { + let response = responses[calls.fetch_add(1, Ordering::SeqCst)].clone(); + Box::pin(async move { + response.ok_or_else(|| GasEstimationError::Other(anyhow!("eth_call failed"))) + }) + as Pin> + Send>> + }; + + let recorder = DebuggingRecorder::new(); + let _guard = metrics::set_default_local_recorder(&recorder); + let result = run_binary_search(round_fn, 1_000_000, max_rounds, ENTRY_POINT, "call").await; + (result, counters(&recorder)) + } + + fn eth_calls(outcome: &str, calls: f64) -> (String, DebugValue) { + ( + format!( + "gas_estimator.eth_calls{{entry_point={ENTRY_POINT},field=call,outcome={outcome}}}" + ), + DebugValue::Histogram(vec![calls.into()]), + ) + } + + #[tokio::test] + async fn test_binary_search_success() { + let (result, metrics) = search(vec![continuation(100, 10_000), converged(5_000)], 3).await; + + assert!(matches!(result, Ok(BinarySearchResult::Success(5_000, 2)))); + assert_eq!(metrics, vec![eth_calls("success", 2.0)]); + } + + #[tokio::test] + async fn test_binary_search_revert() { + let revert = EstimateGasRevertAtMax { + revertData: Bytes::from_static(b"reverted"), + }; + let (result, metrics) = search( + vec![continuation(100, 10_000), Some(revert.abi_encode().into())], + 3, + ) + .await; + + assert!(matches!(result, Ok(BinarySearchResult::Revert(_)))); + assert_eq!(metrics, vec![eth_calls("revert", 2.0)]); + } + + #[tokio::test] + async fn test_binary_search_not_converged() { + let (result, metrics) = search( + vec![ + continuation(100, 10_000), + continuation(200, 9_000), + continuation(300, 8_000), + ], + 3, + ) + .await; + + assert!(result.is_err()); + assert_eq!(metrics, vec![eth_calls("not_converged", 3.0)]); + } + + #[tokio::test] + async fn test_binary_search_failures_record_eth_calls() { + let cases = [ + // no progress on the second continuation + vec![continuation(100, 10_000), continuation(100, 10_000)], + // the second eth_call fails + vec![continuation(100, 10_000), None], + // revert data that is not an estimation result + vec![continuation(100, 10_000), Some(Bytes::from_static(b"bad"))], + ]; + for responses in cases { + let (result, metrics) = search(responses, 3).await; + + assert!(result.is_err()); + assert_eq!(metrics, vec![eth_calls("error", 2.0)]); + } + } } diff --git a/crates/sim/src/estimation/v0_6.rs b/crates/sim/src/estimation/v0_6.rs index bd027bd20..606eb651d 100644 --- a/crates/sim/src/estimation/v0_6.rs +++ b/crates/sim/src/estimation/v0_6.rs @@ -321,14 +321,21 @@ where // Add a buffer to the verification gas limit. Add 10% or 2000 gas, whichever is larger // to ensure we get at least a 2000 gas buffer. Cap at the max verification gas. - let verification_gas_limit = cmp::max( + let buffered_verification_gas_limit = cmp::max( math::increase_by_percent( verification_gas_limit, super::VERIFICATION_GAS_BUFFER_PERCENT, ), verification_gas_limit + simulation::v0_6::REQUIRED_VERIFICATION_GAS_LIMIT_BUFFER, - ) - .min(self.settings.max_verification_gas); + ); + super::record_clamped_estimate( + *self.entry_point.address(), + "verification", + buffered_verification_gas_limit, + self.settings.max_verification_gas, + ); + let verification_gas_limit = + buffered_verification_gas_limit.min(self.settings.max_verification_gas); tracing::debug!( "verification_gas_limit: {} took {:?}ms", @@ -365,8 +372,14 @@ where .await?; // Add a buffer to the call gas limit and clamp - let call_gas_limit = call_gas_limit - .add(super::CALL_GAS_BUFFER_VALUE) + let buffered_call_gas_limit = call_gas_limit.add(super::CALL_GAS_BUFFER_VALUE); + super::record_clamped_estimate( + *self.entry_point.address(), + "call", + buffered_call_gas_limit, + self.settings.max_bundle_execution_gas, + ); + let call_gas_limit = buffered_call_gas_limit .clamp(MIN_CALL_GAS_LIMIT, self.settings.max_bundle_execution_gas); Ok(call_gas_limit) @@ -514,14 +527,22 @@ where Err(e) => GasEstimationError::ProviderError(e), } } + + fn entry_point(&self) -> Address { + *self.entry_point.address() + } } #[cfg(test)] mod tests { - use std::sync::Arc; + use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }; use alloy_primitives::{hex, uint}; use alloy_sol_types::{Revert, SolError, SolValue}; + use metrics_util::debugging::{DebugValue, DebuggingRecorder, Snapshotter}; use rundler_contracts::{ common::EstimationTypes::*, v0_6::UserOperation as ContractUserOperation, }; @@ -540,7 +561,7 @@ mod tests { use crate::{ VerificationGasEstimatorImpl, estimation::{ - CALL_GAS_BUFFER_VALUE, VERIFICATION_GAS_BUFFER_PERCENT, + CALL_GAS_BUFFER_VALUE, ESTIMATION_ETH_CALL_HISTOGRAMS, VERIFICATION_GAS_BUFFER_PERCENT, estimate_call_gas::PROXY_IMPLEMENTATION_ADDRESS_MARKER, }, simulation::v0_6::REQUIRED_VERIFICATION_GAS_LIMIT_BUFFER, @@ -877,6 +898,87 @@ mod tests { assert_eq!(expected_with_buffer, estimation); } + /// Returns `"name{sorted labels}" => value` for the gas estimator search and estimate metrics. + fn estimator_metrics(snapshotter: &Snapshotter) -> Vec<(String, DebugValue)> { + let mut metrics = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter_map(|(key, _, _, value)| { + let key = key.key(); + if !(key.name().ends_with("eth_calls") || key.name().ends_with("clamped_estimates")) + { + return None; + } + let mut labels = key + .labels() + .map(|label| format!("{}={}", label.key(), label.value())) + .collect::>(); + labels.sort(); + Some((format!("{}{{{}}}", key.name(), labels.join(",")), value)) + }) + .collect::>(); + metrics.sort_by(|a, b| a.0.cmp(&b.0)); + metrics + } + + #[tokio::test] + async fn test_binary_search_verification_gas_clamped() { + let (entry, mut provider) = create_base_config(); + let gas_usage = 100_000; + + add_verification_gas_result( + &mut provider, + EstimationTypesErrors::EstimateGasResult(EstimateGasResult { + gas: U256::from(gas_usage), + numRounds: U256::from(10), + }), + ); + + let (_, mut settings) = create_estimator(create_base_config().0, MockEvmProvider::new()); + // below the buffered estimate, so the cap sets the returned limit + settings.max_verification_gas = 140_000; + let estimator = create_custom_estimator( + ChainSpec::default(), + provider, + MockFeeEstimator::new(), + entry, + settings, + ); + + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + let optional_op = demo_user_op_optional_gas(Some(10000)); + let user_op = demo_user_op(); + let estimation = estimator + .estimate_verification_gas(&optional_op, &user_op, B256::ZERO, StateOverride::default()) + .await + .unwrap(); + + assert_eq!(estimation, 140_000); + assert_eq!( + estimator_metrics(&snapshotter), + vec![ + ( + format!( + "gas_estimator.clamped_estimates{{entry_point={},field=verification}}", + Address::ZERO + ), + DebugValue::Counter(1) + ), + ( + format!( + "gas_estimator.eth_calls{{entry_point={},field=verification,outcome=success}}", + Address::ZERO + ), + DebugValue::Histogram(vec![1.0.into()]) + ), + ] + ); + } + #[tokio::test] async fn test_binary_search_verification_gas_should_not_overflow() { let (entry, mut provider) = create_base_config(); @@ -1054,6 +1156,124 @@ mod tests { assert_eq!(estimation, 200); } + #[tokio::test] + async fn test_estimate_call_gas_continuation_records_eth_calls() { + let (mut entry, mut provider) = create_base_config(); + + entry + .expect_simulate_handle_op_estimate_gas() + .returning(|_a, _b, _c, _d, _e| { + Ok(Ok(ExecutionResult { + target_result: EstimateGasContinuation { + minGas: U256::from(100), + maxGas: U256::from(100000), + numRounds: U256::from(10), + } + .abi_encode() + .into(), + target_success: false, + ..Default::default() + })) + }) + .times(1); + entry + .expect_simulate_handle_op_estimate_gas() + .returning(|_a, _b, _c, _d, _e| { + Ok(Ok(ExecutionResult { + target_result: EstimateGasResult { + gas: U256::from(200), + numRounds: U256::from(10), + } + .abi_encode() + .into(), + target_success: true, + ..Default::default() + })) + }) + .times(1); + + provider + .expect_get_code() + .returning(|_a, _b| Ok(Bytes::new())); + + let (estimator, _) = create_estimator(entry, provider); + + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + let estimation = estimator + .call_gas_estimator + .estimate_call_gas(demo_user_op(), B256::ZERO, StateOverride::default()) + .await + .unwrap(); + + assert_eq!(estimation, 200); + assert_eq!( + estimator_metrics(&snapshotter), + vec![( + format!( + "{}{{entry_point={},field=call,outcome=success}}", + ESTIMATION_ETH_CALL_HISTOGRAMS[0], + Address::ZERO + ), + DebugValue::Histogram(vec![2.0.into()]) + )] + ); + } + + #[tokio::test] + async fn test_estimate_call_gas_not_converged() { + let (mut entry, mut provider) = create_base_config(); + + // always asks for another round while making progress, so the search runs out of rounds + let calls = Arc::new(AtomicU64::new(0)); + entry + .expect_simulate_handle_op_estimate_gas() + .returning(move |_a, _b, _c, _d, _e| { + let call = calls.fetch_add(1, Ordering::Relaxed) + 1; + Ok(Ok(ExecutionResult { + target_result: EstimateGasContinuation { + minGas: U256::from(100 * call), + maxGas: U256::from(100000), + numRounds: U256::from(10), + } + .abi_encode() + .into(), + target_success: false, + ..Default::default() + })) + }); + + provider + .expect_get_code() + .returning(|_a, _b| Ok(Bytes::new())); + + let (estimator, settings) = create_estimator(entry, provider); + + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + let error = estimator + .call_gas_estimator + .estimate_call_gas(demo_user_op(), B256::ZERO, StateOverride::default()) + .await + .unwrap_err(); + + assert!(error.to_string().contains("failed to converge")); + assert_eq!( + estimator_metrics(&snapshotter), + vec![( + format!( + "gas_estimator.eth_calls{{entry_point={},field=call,outcome=not_converged}}", + Address::ZERO + ), + DebugValue::Histogram(vec![f64::from(settings.max_gas_estimation_rounds).into()]) + )] + ); + } + #[tokio::test] async fn test_estimation_optional_gas_used() { let (mut entry, mut provider) = create_base_config(); diff --git a/crates/sim/src/estimation/v0_7.rs b/crates/sim/src/estimation/v0_7.rs index ec81b7aa0..59149fb08 100644 --- a/crates/sim/src/estimation/v0_7.rs +++ b/crates/sim/src/estimation/v0_7.rs @@ -379,11 +379,18 @@ where .estimate_verification_gas(full_op, block_hash, state_override) .await?; - let verification_gas_limit = math::increase_by_percent( + let buffered_verification_gas_limit = math::increase_by_percent( verification_gas_limit, super::VERIFICATION_GAS_BUFFER_PERCENT, - ) - .min(self.settings.max_verification_gas); + ); + super::record_clamped_estimate( + *self.entry_point.address(), + "verification", + buffered_verification_gas_limit, + self.settings.max_verification_gas, + ); + let verification_gas_limit = + buffered_verification_gas_limit.min(self.settings.max_verification_gas); tracing::debug!( "verification_gas_limit: {} took {:?}ms", @@ -421,11 +428,18 @@ where .estimate_verification_gas(full_op, block_hash, state_override) .await?; - let paymaster_verification_gas_limit = math::increase_by_percent( + let buffered_paymaster_verification_gas_limit = math::increase_by_percent( paymaster_verification_gas_limit, super::VERIFICATION_GAS_BUFFER_PERCENT, - ) - .min(self.settings.max_verification_gas); + ); + super::record_clamped_estimate( + *self.entry_point.address(), + "paymaster_verification", + buffered_paymaster_verification_gas_limit, + self.settings.max_verification_gas, + ); + let paymaster_verification_gas_limit = + buffered_paymaster_verification_gas_limit.min(self.settings.max_verification_gas); tracing::debug!( "paymaster_verification_gas_limit: {} took {:?}ms", @@ -462,8 +476,14 @@ where .await?; // Add a buffer to the call gas limit and clamp - let call_gas_limit = call_gas_limit - .add(super::CALL_GAS_BUFFER_VALUE) + let buffered_call_gas_limit = call_gas_limit.add(super::CALL_GAS_BUFFER_VALUE); + super::record_clamped_estimate( + *self.entry_point.address(), + "call", + buffered_call_gas_limit, + self.settings.max_bundle_execution_gas, + ); + let call_gas_limit = buffered_call_gas_limit .clamp(MIN_CALL_GAS_LIMIT, self.settings.max_bundle_execution_gas); Ok(call_gas_limit) @@ -646,6 +666,10 @@ where fn decode_revert(&self, revert_data: &Bytes) -> GasEstimationError { decode_validation_revert::(revert_data) } + + fn entry_point(&self) -> Address { + *self.entry_point.address() + } } /// Specialization for paymaster verification gas estimation @@ -681,6 +705,14 @@ where fn decode_revert(&self, revert_data: &Bytes) -> GasEstimationError { decode_validation_revert::(revert_data) } + + fn entry_point(&self) -> Address { + *self.entry_point.address() + } + + fn estimated_field(&self) -> &'static str { + "paymaster_verification" + } } #[cfg(test)] diff --git a/crates/sim/src/lib.rs b/crates/sim/src/lib.rs index a4db16ea1..f3a86b26b 100644 --- a/crates/sim/src/lib.rs +++ b/crates/sim/src/lib.rs @@ -35,9 +35,10 @@ mod estimation; #[cfg(feature = "test-utils")] pub use estimation::MockGasEstimator; pub use estimation::{ - CallGasEstimator, CallGasEstimatorImpl, CallGasEstimatorSpecialization, GasEstimationError, - GasEstimator, GasEstimatorV0_6, GasEstimatorV0_7, Settings as EstimationSettings, - VerificationGasEstimator, VerificationGasEstimatorImpl, + CallGasEstimator, CallGasEstimatorImpl, CallGasEstimatorSpecialization, + ESTIMATION_ETH_CALL_BUCKETS, ESTIMATION_ETH_CALL_HISTOGRAMS, GasEstimationError, GasEstimator, + GasEstimatorV0_6, GasEstimatorV0_7, Settings as EstimationSettings, VerificationGasEstimator, + VerificationGasEstimatorImpl, record_estimation_error, }; /// Gas estimation utilities From cf09d9565de1cf860ebc8d5b1fbaaf7f37266423 Mon Sep 17 00:00:00 2001 From: Ivan Nakov Date: Mon, 28 Sep 2026 14:53:26 +0300 Subject: [PATCH 6/7] feat(builder): label bundle fee counters by sponsorship, drop fee ratio Bundler sponsored ops pay nothing on chain and are billed separately by the paymaster service, so a compensation / fee ratio is meaningless for bundles that contain them. Remove builder_bundle_fee_compensation_ratio; the aggregate for paying bundles can still be computed from the counters. Add has_bundler_sponsored_op (yes, no, or unknown for bundles without user operation events such as reverted ones) to the fee paid and compensation counters, and use the same values on the gas compensation ratio. Drop the sender label from the counters to keep their cardinality independent of the number of sender EOAs. Sponsored ops are detected by an actualGasCost of 0. When the bundle gas price is unknown or 0 every op costs 0, so has_bundler_sponsored_op is unknown and builder_bundle_bundler_sponsored_ops is not recorded. Skip builder_bundle_gas_compensation_ratio on chains that price DA in preVerificationGas but leave it out of the receipt gas used, such as OP stack chains, where sum(actualGasUsed) includes DA gas and the ratio sits far above 1. Chains that include DA gas in the gas limit, such as Arbitrum, keep it. --- crates/builder/src/bundle_metrics.rs | 201 ++++++++++++++++++++------- crates/builder/src/bundle_sender.rs | 3 +- 2 files changed, 149 insertions(+), 55 deletions(-) diff --git a/crates/builder/src/bundle_metrics.rs b/crates/builder/src/bundle_metrics.rs index 79bc1633b..2225613eb 100644 --- a/crates/builder/src/bundle_metrics.rs +++ b/crates/builder/src/bundle_metrics.rs @@ -22,12 +22,9 @@ use metrics_derive::Metrics; use crate::transaction_tracker::MinedUserOpEvent; /// Names of the bundle compensation ratio histograms, without the global metrics prefix. -pub const BUNDLE_RATIO_HISTOGRAMS: &[&str] = &[ - "builder.bundle_gas_compensation_ratio", - "builder.bundle_fee_compensation_ratio", -]; +pub const BUNDLE_RATIO_HISTOGRAMS: &[&str] = &["builder.bundle_gas_compensation_ratio"]; -/// Histogram buckets for the compensation ratios. Values above 1 are expected for larger +/// Histogram buckets for the gas compensation ratio. Values above 1 are expected for larger /// bundles, since each op's pre-verification gas assumes a bundle of one. pub const BUNDLE_RATIO_BUCKETS: &[f64] = &[ 0.0, 0.5, 0.8, 0.9, 0.95, 1.0, 1.05, 1.1, 1.25, 1.5, 2.0, 3.0, 5.0, @@ -47,26 +44,22 @@ const WEI_PER_GWEI: U256 = U256::from_limbs([1_000_000_000, 0, 0, 0]); #[metrics(scope = "builder")] struct BundleCompensationCounters { #[metric( - describe = "the fee paid for mined bundle transactions in gwei (gas used times effective gas price)." + describe = "the fee paid for mined bundle transactions in gwei (gas used times effective gas price). has_bundler_sponsored_op is unknown for bundles without user operation events, such as reverted bundles." )] bundle_fee_paid_gwei: Counter, #[metric( - describe = "the compensation paid to the bundler by the entry point for mined bundles in gwei (sum of actualGasCost)." + describe = "the compensation paid to the bundler by the entry point for mined bundles in gwei (sum of actualGasCost). Bundler sponsored ops pay nothing on chain and are billed separately." )] bundle_compensation_gwei: Counter, } #[derive(Metrics)] #[metrics(scope = "builder")] -struct BundleCompensationRatios { +struct BundleGasCompensation { #[metric( - describe = "the gas the entry point charged ops for divided by the gas the bundle transaction used, for successful bundles." + describe = "the gas the entry point charged ops for (sum of actualGasUsed) divided by the gas the bundle transaction used, for successful bundles, including bundler sponsored ones. Not recorded on chains that price DA in preVerificationGas but leave it out of the receipt gas used, such as OP stack chains." )] bundle_gas_compensation_ratio: Histogram, - #[metric( - describe = "the compensation paid to the bundler divided by the bundle transaction fee, for successful bundles." - )] - bundle_fee_compensation_ratio: Histogram, } #[derive(Metrics)] @@ -75,7 +68,7 @@ struct BundleOpCounts { #[metric(describe = "the number of user operations in a successful mined bundle.")] bundle_ops: Histogram, #[metric( - describe = "the number of bundler sponsored user operations (actualGasCost of 0) in a successful mined bundle." + describe = "the number of bundler sponsored user operations (actualGasCost of 0) in a successful mined bundle. Not recorded when the bundle gas price is unknown or 0, since then every op has an actualGasCost of 0." )] bundle_bundler_sponsored_ops: Histogram, } @@ -106,9 +99,13 @@ fn ratio(numerator: U256, denominator: U256) -> Option { /// /// Only events emitted by `entry_point` are counted. Reverted bundles have no events, so they /// only update the counters. +/// +/// `da_gas_in_gas_used` is false on chains where ops pay for DA through preVerificationGas but +/// the receipt gas used leaves it out, such as OP stack chains. The gas compensation ratio is +/// meaningless there and is not recorded. pub(crate) fn record_mined_bundle( entry_point: Address, - sender: Address, + da_gas_in_gas_used: bool, is_success: bool, gas_used: Option, gas_price: Option, @@ -117,7 +114,7 @@ pub(crate) fn record_mined_bundle( // `new_with_labels` does not register the metric descriptions DESCRIBE.call_once(|| { BundleCompensationCounters::describe(); - BundleCompensationRatios::describe(); + BundleGasCompensation::describe(); BundleOpCounts::describe(); }); @@ -125,6 +122,18 @@ pub(crate) fn record_mined_bundle( .iter() .filter(|event| event.entry_point == entry_point) .collect::>(); + // With a gas price of 0 every op has an actualGasCost of 0, so sponsored ops can't be told apart + let sponsored_ops = gas_price.filter(|price| *price > 0).map(|_| { + events + .iter() + .filter(|event| event.actual_gas_cost.is_zero()) + .count() + }); + let has_bundler_sponsored_op = match sponsored_ops { + Some(0) if !events.is_empty() => "no", + Some(1..) => "yes", + _ => "unknown", + }; let compensation = events.iter().fold(U256::ZERO, |sum, event| { sum.saturating_add(event.actual_gas_cost) }); @@ -134,8 +143,11 @@ pub(crate) fn record_mined_bundle( let counters = BundleCompensationCounters::new_with_labels(&[ ("entry_point", entry_point.to_string()), - ("sender", sender.to_string()), ("success", is_success.to_string()), + ( + "has_bundler_sponsored_op", + has_bundler_sponsored_op.to_string(), + ), ]); if let Some(fee) = fee { counters.bundle_fee_paid_gwei.increment(wei_to_gwei(fee)); @@ -151,29 +163,32 @@ pub(crate) fn record_mined_bundle( return; } - let sponsored_ops = events - .iter() - .filter(|event| event.actual_gas_cost.is_zero()) - .count(); let op_counts = BundleOpCounts::new_with_labels(&[("entry_point", entry_point.to_string())]); op_counts.bundle_ops.record(events.len() as f64); - op_counts - .bundle_bundler_sponsored_ops - .record(sponsored_ops as f64); + if let Some(sponsored_ops) = sponsored_ops { + op_counts + .bundle_bundler_sponsored_ops + .record(sponsored_ops as f64); + } + + if !da_gas_in_gas_used { + return; + } - let ratios = BundleCompensationRatios::new_with_labels(&[ - ("entry_point", entry_point.to_string()), - ("bundle_size", bundle_size_label(events.len()).to_string()), - ("has_bundler_sponsored_op", (sponsored_ops > 0).to_string()), - ]); let charged_gas = events.iter().fold(U256::ZERO, |sum, event| { sum.saturating_add(event.actual_gas_used) }); if let Some(gas_ratio) = ratio(charged_gas, U256::from(gas_used)) { - ratios.bundle_gas_compensation_ratio.record(gas_ratio); - } - if let Some(fee_ratio) = fee.and_then(|fee| ratio(compensation, fee)) { - ratios.bundle_fee_compensation_ratio.record(fee_ratio); + BundleGasCompensation::new_with_labels(&[ + ("entry_point", entry_point.to_string()), + ("bundle_size", bundle_size_label(events.len()).to_string()), + ( + "has_bundler_sponsored_op", + has_bundler_sponsored_op.to_string(), + ), + ]) + .bundle_gas_compensation_ratio + .record(gas_ratio); } } @@ -187,7 +202,6 @@ mod tests { use super::*; const ENTRY_POINT: Address = address!("0000000071727De22E5E9d8BAf0edAc6f37da032"); - const SENDER: Address = address!("00000000000000000000000000000000000000aa"); const OTHER: Address = address!("00000000000000000000000000000000000000bb"); fn event(entry_point: Address, actual_gas_cost: u64, actual_gas_used: u64) -> MinedUserOpEvent { @@ -204,11 +218,28 @@ mod tests { gas_used: Option, gas_price: Option, events: &[MinedUserOpEvent], + ) -> BTreeMap { + record_on_chain(true, is_success, gas_used, gas_price, events) + } + + fn record_on_chain( + da_gas_in_gas_used: bool, + is_success: bool, + gas_used: Option, + gas_price: Option, + events: &[MinedUserOpEvent], ) -> BTreeMap { let recorder = DebuggingRecorder::new(); let snapshotter = recorder.snapshotter(); metrics::with_local_recorder(&recorder, || { - record_mined_bundle(ENTRY_POINT, SENDER, is_success, gas_used, gas_price, events); + record_mined_bundle( + ENTRY_POINT, + da_gas_in_gas_used, + is_success, + gas_used, + gas_price, + events, + ); }); snapshotter @@ -219,7 +250,7 @@ mod tests { let key = key.key(); let mut labels = key .labels() - .filter(|label| !matches!(label.key(), "entry_point" | "sender")) + .filter(|label| label.key() != "entry_point") .map(|label| format!("{}={}", label.key(), label.value())) .collect::>(); labels.sort(); @@ -259,17 +290,18 @@ mod tests { ], ); - let ratio_labels = "{bundle_size=2-4,has_bundler_sponsored_op=true}"; + let counter_labels = "{has_bundler_sponsored_op=yes,success=true}"; + let ratio_labels = "{bundle_size=2-4,has_bundler_sponsored_op=yes}"; assert_eq!( metrics, BTreeMap::from([ ( - "builder.bundle_fee_paid_gwei{success=true}".to_string(), + format!("builder.bundle_fee_paid_gwei{counter_labels}"), // 300_000 gas * 10 gwei DebugValue::Counter(3_000_000) ), ( - "builder.bundle_compensation_gwei{success=true}".to_string(), + format!("builder.bundle_compensation_gwei{counter_labels}"), DebugValue::Counter(3_300_000) ), ( @@ -285,11 +317,6 @@ mod tests { // 360_000 charged / 300_000 used DebugValue::Histogram(vec![1.2.into()]) ), - ( - format!("{}{ratio_labels}", BUNDLE_RATIO_HISTOGRAMS[1]), - // 3_300_000 gwei paid back / 3_000_000 gwei fee - DebugValue::Histogram(vec![1.1.into()]) - ), ]) ); assert!(BUNDLE_OP_COUNT_HISTOGRAMS.iter().all(|name| { @@ -307,11 +334,13 @@ mod tests { metrics, BTreeMap::from([ ( - "builder.bundle_fee_paid_gwei{success=false}".to_string(), + "builder.bundle_fee_paid_gwei{has_bundler_sponsored_op=unknown,success=false}" + .to_string(), DebugValue::Counter(100_000) ), ( - "builder.bundle_compensation_gwei{success=false}".to_string(), + "builder.bundle_compensation_gwei{has_bundler_sponsored_op=unknown,success=false}" + .to_string(), DebugValue::Counter(0) ), ]) @@ -319,7 +348,34 @@ mod tests { } #[test] - fn test_unknown_or_zero_fee_skips_fee_ratio() { + fn test_bundle_without_sponsored_ops() { + let metrics = record( + true, + Some(100_000), + Some(1_000_000_000), + &[event(ENTRY_POINT, 90_000_000_000_000, 90_000)], + ); + + assert_eq!( + metrics + .get("builder.bundle_compensation_gwei{has_bundler_sponsored_op=no,success=true}"), + Some(&DebugValue::Counter(90_000)) + ); + assert_eq!( + metrics.get(&format!( + "{}{{bundle_size=1,has_bundler_sponsored_op=no}}", + BUNDLE_RATIO_HISTOGRAMS[0] + )), + Some(&DebugValue::Histogram(vec![0.9.into()])) + ); + assert_eq!( + metrics.get("builder.bundle_bundler_sponsored_ops{}"), + Some(&DebugValue::Histogram(vec![0.0.into()])) + ); + } + + #[test] + fn test_unknown_or_zero_fee_does_not_detect_sponsored_ops() { for gas_price in [None, Some(0)] { let metrics = record( true, @@ -328,20 +384,57 @@ mod tests { &[event(ENTRY_POINT, 0, 90_000)], ); - let ratio_labels = "{bundle_size=1,has_bundler_sponsored_op=true}"; + // every op costs 0 at a gas price of 0, so sponsorship is unknown assert_eq!( - metrics.get(&format!("{}{ratio_labels}", BUNDLE_RATIO_HISTOGRAMS[0])), + metrics.get(&format!( + "{}{{bundle_size=1,has_bundler_sponsored_op=unknown}}", + BUNDLE_RATIO_HISTOGRAMS[0] + )), Some(&DebugValue::Histogram(vec![0.9.into()])) ); - // registered alongside the gas ratio, but no fee ratio sample is recorded + // registered with the compensation counter, nothing added assert_eq!( - metrics.get(&format!("{}{ratio_labels}", BUNDLE_RATIO_HISTOGRAMS[1])), - Some(&DebugValue::Histogram(vec![])) + metrics.get( + "builder.bundle_fee_paid_gwei{has_bundler_sponsored_op=unknown,success=true}" + ), + Some(&DebugValue::Counter(0)) ); assert_eq!( - metrics.get("builder.bundle_fee_paid_gwei{success=true}"), - Some(&DebugValue::Counter(0)) + metrics.get("builder.bundle_ops{}"), + Some(&DebugValue::Histogram(vec![1.0.into()])) + ); + // registered with the op count histogram, nothing recorded + assert_eq!( + metrics.get("builder.bundle_bundler_sponsored_ops{}"), + Some(&DebugValue::Histogram(vec![])) ); } } + + #[test] + fn test_da_gas_outside_gas_used_skips_gas_ratio() { + let metrics = record_on_chain( + false, + true, + Some(50_000), + Some(1_000_000_000), + // pre-verification gas includes DA gas the receipt gas used leaves out + &[event(ENTRY_POINT, 350_000_000_000_000, 350_000)], + ); + + assert!( + !metrics + .keys() + .any(|key| key.starts_with(BUNDLE_RATIO_HISTOGRAMS[0])) + ); + assert_eq!( + metrics + .get("builder.bundle_compensation_gwei{has_bundler_sponsored_op=no,success=true}"), + Some(&DebugValue::Counter(350_000)) + ); + assert_eq!( + metrics.get("builder.bundle_ops{}"), + Some(&DebugValue::Histogram(vec![1.0.into()])) + ); + } } diff --git a/crates/builder/src/bundle_sender.rs b/crates/builder/src/bundle_sender.rs index d3f1827fd..6e6efe466 100644 --- a/crates/builder/src/bundle_sender.rs +++ b/crates/builder/src/bundle_sender.rs @@ -647,7 +647,8 @@ where if let Some((entry_point, _)) = &pinned { bundle_metrics::record_mined_bundle( *entry_point, - self.sender_eoa, + !self.chain_spec.da_pre_verification_gas + || self.chain_spec.include_da_gas_in_gas_limit, is_success, gas_used, gas_price, From 603ad4edca98daca58b780de080e43592e10ec53 Mon Sep 17 00:00:00 2001 From: Ivan Nakov Date: Mon, 28 Sep 2026 17:17:41 +0300 Subject: [PATCH 7/7] chore: declare metrics-util as a workspace dependency cargo-deny rejects metrics-util being declared in five crates without a shared workspace dependency. Declare it once in the workspace and inherit it, enabling the debugging feature only in the test-only dependencies. --- Cargo.toml | 1 + bin/rundler/Cargo.toml | 2 +- crates/builder/Cargo.toml | 2 +- crates/pool/Cargo.toml | 2 +- crates/sim/Cargo.toml | 2 +- crates/types/Cargo.toml | 2 +- 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 764077a09..e0831e0d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,6 +83,7 @@ jsonrpsee = "0.24.9" http = "1.1.0" metrics = "0.24.1" metrics-derive = "0.1.0" +metrics-util = "0.19.0" mockall = "0.13.0" parking_lot = "0.12.3" parse-display = "0.10.0" diff --git a/bin/rundler/Cargo.toml b/bin/rundler/Cargo.toml index 4185eb18b..1e18da283 100644 --- a/bin/rundler/Cargo.toml +++ b/bin/rundler/Cargo.toml @@ -32,7 +32,7 @@ metrics.workspace = true metrics-derive.workspace = true metrics-exporter-prometheus = { version = "0.16.2", default-features = false, features = ["http-listener"] } metrics-process = "2.4.0" -metrics-util = "0.19.0" +metrics-util.workspace = true opentelemetry = "0.32.0" opentelemetry-otlp = { version = "0.32.0", features = ["grpc-tonic"] } opentelemetry_sdk = "0.32.1" diff --git a/crates/builder/Cargo.toml b/crates/builder/Cargo.toml index 48aca02c5..59acd9364 100644 --- a/crates/builder/Cargo.toml +++ b/crates/builder/Cargo.toml @@ -51,7 +51,7 @@ tracing.workspace = true alloy-json-rpc.workspace = true alloy-rpc-types-eth.workspace = true alloy-transport.workspace = true -metrics-util = { version = "0.19.0", default-features = false, features = ["debugging"] } +metrics-util = { workspace = true, features = ["debugging"] } mockall.workspace = true rundler-provider = { workspace = true, features = ["test-utils"] } rundler-sim = { workspace = true, features = ["test-utils"] } diff --git a/crates/pool/Cargo.toml b/crates/pool/Cargo.toml index 40b5c72f0..01d84dfd6 100644 --- a/crates/pool/Cargo.toml +++ b/crates/pool/Cargo.toml @@ -44,7 +44,7 @@ alloy-eips.workspace = true alloy-network-primitives.workspace = true alloy-rpc-types-eth.workspace = true alloy-serde.workspace = true -metrics-util = { version = "0.19.0", default-features = false, features = ["debugging"] } +metrics-util = { workspace = true, features = ["debugging"] } mockall.workspace = true reth-tasks.workspace = true rundler-provider = { workspace = true, features = ["test-utils"] } diff --git a/crates/sim/Cargo.toml b/crates/sim/Cargo.toml index ffdcbcad9..464915dfb 100644 --- a/crates/sim/Cargo.toml +++ b/crates/sim/Cargo.toml @@ -36,7 +36,7 @@ alloy-primitives = { workspace = true, features = ["rand"] } alloy-signer.workspace = true alloy-signer-local.workspace = true alloy-transport.workspace = true -metrics-util = { version = "0.19.0", default-features = false, features = ["debugging"] } +metrics-util = { workspace = true, features = ["debugging"] } mockall.workspace = true rundler-provider = { workspace = true, features = ["test-utils"] } rundler-types = { workspace = true, features = ["test-utils"] } diff --git a/crates/types/Cargo.toml b/crates/types/Cargo.toml index cb8fa0827..6cc300ed3 100644 --- a/crates/types/Cargo.toml +++ b/crates/types/Cargo.toml @@ -32,7 +32,7 @@ thiserror.workspace = true [dev-dependencies] alloy-primitives = { workspace = true, features = ["rand"] } -metrics-util = { version = "0.19.0", default-features = false, features = ["debugging"] } +metrics-util = { workspace = true, features = ["debugging"] } serde_json.workspace = true [features]