Skip to content

feat: add gas visibility metrics for estimation, pool and bundles - #1341

Merged
inakov merged 7 commits into
mainfrom
ivan/gas-visibility-metrics
Oct 1, 2026
Merged

inakov merged 7 commits into
mainfrom
ivan/gas-visibility-metrics

Conversation

@inakov

@inakov inakov commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Instrumentation only: new metrics, no change to estimation, pool, builder or RPC behaviour. Every returned value, error and state transition is unchanged. One commit per metric group.

Motivation

On a Glamsterdam devnet, sponsored EIP-7702 ops failed with AA26 over verificationGasLimit and no metric showed it.

  • The cause is EIP-8037 state gas. The estimation eth_call runs with max_gas_estimation_gas (550M), far above 2^24, so state gas is paid from a reservoir that gasleft(), and therefore the EntryPoint's metering, cannot see.
  • Verification estimates come out too low. A bundle transaction with more than 2^24 gas would also under-report actualGasUsed on chain.
  • Today RPC errors are counted only by JSON-RPC code, bundle simulation failures carry no reason, and the estimator only has timing histograms.

These metrics make that visible before we change estimation, so the fix's effect can be seen in Grafana.

Proposed Changes

  • AA errors by stage (types, pool, builder, rpc): rundler_entry_point_aa_errors{stage, aa_code, entry_point}.
    • stage is estimation, pool_admission, bundle_revalidation, bundle_handle_ops or onchain.
    • aa_code is AA plus two digits, or none. It's parsed with a check that never panics, and never contains revert text.
    • It shows which AA error happens, and at which stage.
    • onchain is recorded before the submission proxy handles the revert, so it's counted even when the proxy attributes it.
  • Gas used vs limit on mined ops (pool): rundler_op_pool_mined_op_gas_efficiency, a histogram of actualGasUsed / gas limit. The limit is the EntryPoint's own prefund formula, including preVerificationGas.
    • Labels: entry_point, success, has_paymaster, has_factory, has_7702_auth, fresh_nonce_slot.
    • success=false exposes call-phase failures, which never produce an AA code on v0.7+.
    • The other labels single out ops that create new state, which is what EIP-8037 affects.
    • MinedOp now keeps success and actualGasUsed from UserOperationEvent.
    • It's recorded only when the pool copy's hash matches the mined op, so a replacement with different limits isn't used.
  • Verification gas efficiency at pool admission (pool): rundler_op_pool_admission_verification_gas_efficiency, with the same labels minus success.
    • It's recorded for every op that passes simulation, before the existing reject-threshold check. The ratio is computed once and reused by the check, whose outcome is unchanged.
    • That check only computed this ratio when its threshold is above 0, which isn't the default.
    • The value is on the threshold's scale: v0.6 ops with a paymaster, where the check halves the threshold, are measured against one verification gas limit and can exceed 1. An op is rejected exactly when its value is below the configured threshold.
  • Bundle cost vs compensation (builder): the bundler is the handleOps beneficiary, so a mined bundle is paid back Σ actualGasCost of its UserOperationEvents.
    • The transaction tracker now keeps those events, with their emitter, from the receipt it already fetches. No new RPC calls.
    • The sender keeps only events from the pinned EntryPoint, so a look-alike event from an account contract is ignored.
    • Metrics:
      • rundler_builder_bundle_fee_paid_gwei and rundler_builder_bundle_compensation_gwei (counters). Labels: entry_point, success, has_bundler_sponsored_op (yes / no / unknown). unknown is for bundles without events, such as reverted ones, and for a bundle gas price that is unknown or 0, since then every op has an actualGasCost of 0.
      • rundler_builder_bundle_gas_compensation_ratio: Σ actualGasUsed ÷ receipt gasUsed. Labels: entry_point, bundle_size (1 / 2-4 / 5-9 / 10+), has_bundler_sponsored_op. Below 1.0 means ops were charged for less gas than the bundle used, which is the direct EIP-8037 signal. Not recorded on chains that price DA in preVerificationGas but leave it out of the receipt gasUsed, such as OP-stack chains; Arbitrum includes DA gas in the gas limit and keeps it.
      • rundler_builder_bundle_ops and rundler_builder_bundle_bundler_sponsored_ops: op counts per successful bundle, so the share of bundler-sponsored ops can be computed. The sponsored count isn't recorded when the bundle gas price is unknown or 0.
    • Bundler-sponsored ops pay nothing on chain and are billed separately, so there's no fee ratio histogram. For bundles where every op paid, compensation ÷ fee can be computed from the counters with has_bundler_sponsored_op="no".
  • Estimator internals (sim, rpc), with field = verification / paymaster_verification / call:
    • rundler_gas_estimator_eth_calls{entry_point, field, outcome}: eth_calls per binary search, recorded on every exit and counting a call that failed. Lowering max_gas_estimation_gas means more continuation calls.
      • outcome is success, revert, error, or not_converged when the search used all max_gas_estimation_rounds. Today that failure is only a GasEstimationError::Other; its count is rundler_gas_estimator_eth_calls_count{outcome="not_converged"}.
    • rundler_gas_estimator_clamped_estimates{entry_point, field}: estimates whose buffered value was cut down to max_verification_gas or max_bundle_execution_gas, which removes the buffer.
    • rundler_gas_estimator_errors{entry_point, kind}: eth_estimateUserOperationGas errors by GasEstimationError variant, recorded in the RPC router. Several variants share one JSON-RPC code.
  • Histogram buckets: each new histogram gets its own buckets via set_buckets_for_metric, because the global buckets are sized for milliseconds. The bucket lists are defined next to the metrics, and tests assert that the real metric names match them.

Cardinality

  • Labels are booleans, fixed sets of values from our code, or the EntryPoint address.
  • No revert text and no sender label on the new metrics.
  • The largest is the mined-op histogram: at most 64 label combinations per EntryPoint, and realistically under 10.

Testing

  • make fmt, make lint and make test-unit pass: 575 tests, 29 of them new.
  • New tests use metrics_util::debugging::DebuggingRecorder and check metric names, labels and values. They're added as a dev-dependency to rundler-types, rundler-pool, rundler-builder and rundler-sim.
  • AA errors were checked on a Glamsterdam devnet: stage="pool_admission",aa_code="AA26" increments.
  • Not run yet: spec tests, and a localdev scrape of the newer metrics. Those are the custom buckets and HELP lines, and bundle values against a receipt.

Known limits

  • Penalty: actualGasUsed includes the v0.7+ unused-gas penalty, so mined-op efficiency reads slightly high for ops with a large unused call gas limit.
  • Reorgs: a mined op seen again after a reorg is recorded twice, the same as time_to_mine.
  • Rounding: the gwei counters drop fractions of a gwei per bundle.
  • OP-stack chains: fee paid is gas used × effective gas price, and leaves out the separate L1 data fee. The gas compensation ratio isn't recorded there.
  • Sponsorship detection: a bundler-sponsored op is detected by an actualGasCost of 0. Only wallet-server's wallet_sendPreparedCalls requires sponsored ops to have zero fees; eth_sendUserOperation with a policy header, the paymaster and rundler don't check. A sponsored op with nonzero fees would pay on chain and be counted as has_bundler_sponsored_op="no".

@codecov

codecov Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.23810% with 72 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.10%. Comparing base (1dad663) to head (603ad4e).

Files with missing lines Patch % Lines
bin/rundler/src/cli/metrics.rs 0.00% 20 Missing ⚠️
crates/rpc/src/eth/router.rs 0.00% 14 Missing ⚠️
crates/builder/src/bundle_proposer.rs 45.83% 13 Missing ⚠️
crates/pool/src/mempool/uo_pool.rs 93.33% 10 Missing ⚠️
crates/sim/src/estimation/v0_7.rs 0.00% 9 Missing ⚠️
crates/builder/src/bundle_metrics.rs 99.62% 1 Missing ⚠️
crates/builder/src/bundle_sender.rs 93.75% 1 Missing ⚠️
crates/builder/src/transaction_tracker.rs 99.15% 1 Missing ⚠️
crates/sim/src/estimation/v0_6.rs 99.41% 1 Missing ⚠️
crates/sim/src/simulation/mod.rs 85.71% 1 Missing ⚠️
... and 1 more
Additional details and impacted files

Impacted file tree graph

Files with missing lines Coverage Δ
crates/pool/src/chain.rs 94.08% <100.00%> (+0.15%) ⬆️
crates/pool/src/mempool/gas_metrics.rs 100.00% <100.00%> (ø)
crates/pool/src/mempool/mod.rs 100.00% <ø> (ø)
crates/pool/src/mempool/pool.rs 97.89% <100.00%> (+<0.01%) ⬆️
crates/sim/src/estimation/estimate_call_gas.rs 88.78% <100.00%> (+0.21%) ⬆️
...es/sim/src/estimation/estimate_verification_gas.rs 92.04% <100.00%> (+0.47%) ⬆️
crates/sim/src/estimation/mod.rs 99.12% <100.00%> (+11.62%) ⬆️
crates/types/src/pool/error.rs 86.48% <100.00%> (+86.48%) ⬆️
crates/types/src/validation_results.rs 37.96% <100.00%> (+19.96%) ⬆️
crates/builder/src/bundle_metrics.rs 99.62% <99.62%> (ø)
... and 10 more

... and 4 files with indirect coverage changes

Flag Coverage Δ
unit-tests 65.10% <95.23%> (+1.31%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
rundler binary 10.14% <0.00%> (-0.13%) ⬇️
builder 77.47% <96.26%> (+0.81%) ⬆️
dev ∅ <ø> (∅)
pool 68.57% <97.63%> (+1.15%) ⬆️
provider 30.90% <ø> (ø)
rpc 46.13% <0.00%> (-0.17%) ⬇️
sim 81.24% <97.66%> (+1.57%) ⬆️
tasks ∅ <ø> (∅)
types 74.63% <99.35%> (+2.91%) ⬆️
utils 53.40% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The changes are instrumentation-only with comprehensive unit tests added, and the remaining review notes are minor metric HELP-text wording fixes.

Review effort: Lite
Findings: 2 Low severity

Open (2)
What changed in this PR

Adds new Prometheus metrics across types, sim, rpc, pool, builder, and the CLI metrics exporter to make AA-stage failures and gas accounting inefficiencies (notably around >2²⁴ gas / EIP-8037 “state gas”) observable without changing functional behavior.

Changes:

  • Introduce a bounded AaErrorCode parser and a shared record_aa_error helper to count AA errors by stage/entrypoint without leaking revert text.
  • Add pool and builder “gas efficiency / compensation” histograms + counters (mined op efficiency, admission verification efficiency, bundle fee vs compensation, charged-gas vs tx gas-used ratio).
  • Add estimator internal instrumentation (eth_call counts, non-convergence, clamped estimates, error kinds) and wire per-metric histogram buckets in the CLI exporter.
File Description
crates/​types/​src/​validation_results.rs Adds AaErrorCode parsing and exposes AA code extraction from ValidationRevert + tests.
crates/​types/​src/​pool/​error.rs Exposes AA code extraction for SimulationViolation so callers can record stage metrics.
crates/​types/​src/​lib.rs Re-exports AaErrorCode and introduces shared entry_point_metrics module.
crates/​types/​src/​entry_point_metrics.rs New shared AA-error counter metric + stage enum and tests.
crates/​types/​Cargo.toml Adds metrics-util dev-dependency for recorder-based metric tests.
crates/​sim/​src/​simulation/​mod.rs Adds SimulationError::aa_error_code() helper for metric labeling.
crates/​sim/​src/​lib.rs Re-exports estimator metric constants and record_estimation_error.
crates/​sim/​src/​estimation/​v0_7.rs Records “clamped estimate” metrics and labels paymaster verification field correctly.
crates/​sim/​src/​estimation/​v0_6.rs Records “clamped estimate” metrics and adds/extends tests for estimator metrics.
crates/​sim/​src/​estimation/​mod.rs Adds estimator search/nonconvergence/clamp/error metrics and their recording points + tests.
crates/​sim/​src/​estimation/​estimate_verification_gas.rs Threads a field label through binary search for verification estimation metrics.
crates/​sim/​src/​estimation/​estimate_call_gas.rs Threads field="call" into the binary search metric recording.
crates/​sim/​Cargo.toml Adds metrics-util dev-dependency for metric tests.
crates/​rpc/​src/​eth/​router.rs Records estimator error-kind metrics and AA validation errors during eth_estimateUserOperationGas.
crates/​pool/​src/​mempool/​uo_pool.rs Records AA errors at pool admission + mined/admission gas-efficiency metrics; updates tests.
crates/​pool/​src/​mempool/​pool.rs Updates pool tests for expanded MinedOp fields.
crates/​pool/​src/​mempool/​mod.rs Introduces and exports pool gas metric constants.
crates/​pool/​src/​mempool/​gas_metrics.rs New pool gas-efficiency histograms (mined op and admission verification) with bounded labels + tests.
crates/​pool/​src/​lib.rs Re-exports pool gas metric constants.
crates/​pool/​src/​chain.rs Extends MinedOp with success + actualGasUsed and preserves them when parsing events; tests.
crates/​pool/​Cargo.toml Adds metrics-util dev-dependency for metric tests.
crates/​builder/​src/​transaction_tracker.rs Captures UserOperationEvent logs from receipts into TrackerUpdate::Mined (no extra RPC).
crates/​builder/​src/​lib.rs Exports bundle metric constants for CLI bucket wiring.
crates/​builder/​src/​bundle_sender.rs Records mined-bundle metrics on receipt processing.
crates/​builder/​src/​bundle_proposer.rs Records AA errors at bundle revalidation / handleOps simulation / onchain revert parsing.
crates/​builder/​src/​bundle_metrics.rs New builder bundle compensation/fee counters and gas-compensation ratio + op-count histograms with tests.
crates/​builder/​Cargo.toml Adds dependencies needed for log decoding + metrics-util dev-dependency.
Cargo.lock Locks new dependencies (metrics-util, rundler-contracts, alloy-sol-types) pulled into crates/tests.
bin/​rundler/​src/​cli/​metrics.rs Wires per-metric histogram buckets via set_buckets_for_metric for the new ratio/count histograms.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread crates/sim/src/estimation/mod.rs
Comment thread crates/types/src/entry_point_metrics.rs Outdated
@inakov
inakov requested a review from rado-alchemy September 28, 2026 14:21

@jakehobbs jakehobbs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex review: I found two cases where the new metrics can misrepresent production behavior.

Comment thread crates/builder/src/bundle_metrics.rs Outdated
.collect::<Vec<_>>();
let sponsored_ops = events
.iter()
.filter(|event| event.actual_gas_cost.is_zero())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When an op has bundler_sponsorship but retains nonzero fee caps, the proposer still accepts it without clearing those caps, so its UserOperationEvent.actualGasCost can be positive. This classifies a sponsored bundle as has_bundler_sponsored_op=no and records zero sponsored ops. Classify sponsorship from the op permission or another tracked identifier rather than from zero gas cost. — Codex

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not actually be possible. The proxy (wallet server) in front of wallet should reject in this case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked the whole path. The zero-fee check only exists in wallet-server's wallet_sendPreparedCalls, which rejects a sponsored op unless maxFeePerGas, maxPriorityFeePerGas, preVerificationGas and paymasterVerificationGasLimit are all 0. eth_sendUserOperation with an x-alchemy-policy-id header forwards sponsored ops without checking fees, and neither the paymaster nor rundler checks them. So an op sent that way with nonzero fees would pay actualGasCost on chain and be counted as has_bundler_sponsored_op="no".

I'm keeping zero-cost detection here: detecting from permissions would mean changing the bundle sender's state, and this PR is metrics-only. The gap is noted in Known limits and I'll raise the eth_sendUserOperation check with the wallet-server owners separately.

Comment thread crates/sim/src/estimation/mod.rs Outdated
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.
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.
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.
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.
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.
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.
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.
@inakov
inakov force-pushed the ivan/gas-visibility-metrics branch from 2428825 to 603ad4e Compare September 30, 2026 09:17
@inakov
inakov merged commit 2dc1788 into main Oct 1, 2026
22 checks passed
@inakov
inakov deleted the ivan/gas-visibility-metrics branch October 1, 2026 07:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants