Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/client/metering/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ alloy-eips.workspace = true
# rpc
jsonrpsee.workspace = true

# DA calculation
op-alloy-flz.workspace = true

# misc
tracing.workspace = true
eyre.workspace = true
Expand Down
50 changes: 50 additions & 0 deletions crates/client/metering/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,53 @@ Re-executes a block by number and returns timing metrics.

**Returns:**
- `MeterBlockResponse`: Contains timing breakdown for signer recovery, EVM execution, and state root calculation

### `base_meteredPriorityFeePerGas`

Meters a bundle and returns a recommended priority fee based on recent block congestion.

**Parameters:**
- `bundle`: Bundle object containing transactions to simulate

**Returns:**
- `MeteredPriorityFeeResponse`: Contains metering results plus priority fee recommendation

**Response:**
```json
{
"bundleGasPrice": "0x...",
"bundleHash": "0x...",
"results": [...],
"totalGasUsed": 21000,
"totalExecutionTimeUs": 1234,
"priorityFee": "0x5f5e100",
"blocksSampled": 1,
"resourceEstimates": [
{
"resource": "gasUsed",
"thresholdPriorityFee": "0x3b9aca00",
"recommendedPriorityFee": "0x5f5e100",
"cumulativeUsage": "0x1e8480",
"thresholdTxCount": 5,
"totalTransactions": 10
},
{
"resource": "executionTime",
...
},
{
"resource": "dataAvailability",
...
}
]
}
```

**Algorithm:**
1. Meter the bundle to get resource consumption (gas, execution time, DA bytes)
2. Meter the latest block to get historical transaction data
3. For each resource type, run the estimation algorithm:
- Walk from highest-paying transactions, subtracting usage from remaining capacity
- Stop when adding another tx would leave less room than the bundle needs
- The last included tx's fee is the threshold
4. Return the maximum fee across all resources as `priorityFee`
310 changes: 310 additions & 0 deletions crates/client/metering/src/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,313 @@ where
transactions: transaction_times,
})
}

#[cfg(test)]
mod tests {
use alloy_consensus::TxEip1559;
use alloy_primitives::{Address, Signature};
use base_client_node::test_utils::{Account, TestHarness};
use reth_optimism_primitives::{OpBlockBody, OpTransactionSigned};
use reth_primitives_traits::Block as _;
use reth_transaction_pool::test_utils::TransactionBuilder;

use super::*;

fn create_block_with_transactions(
harness: &TestHarness,
transactions: Vec<OpTransactionSigned>,
) -> OpBlock {
let latest = harness.latest_block();
let header = Header {
parent_hash: latest.hash(),
number: latest.number() + 1,
timestamp: latest.timestamp() + 2,
gas_limit: 30_000_000,
beneficiary: Address::random(),
base_fee_per_gas: Some(1),
// Required for post-Cancun blocks (EIP-4788)
parent_beacon_block_root: Some(B256::ZERO),
..Default::default()
};

let body = OpBlockBody { transactions, ommers: vec![], withdrawals: None };

OpBlock::new(header, body)
}

#[tokio::test]
async fn meter_block_empty_transactions() -> eyre::Result<()> {
let harness = TestHarness::new().await?;

let block = create_block_with_transactions(&harness, vec![]);

let response = meter_block(harness.blockchain_provider(), harness.chain_spec(), &block)?;

assert_eq!(response.block_hash, block.header().hash_slow());
assert_eq!(response.block_number, block.header().number());
assert!(response.transactions.is_empty());
// No transactions means minimal signer recovery time (just timing overhead)
assert!(
response.execution_time_us > 0,
"execution time should be non-zero due to EVM setup"
);
assert!(response.state_root_time_us > 0, "state root time should be non-zero");
assert_eq!(
response.total_time_us,
response.signer_recovery_time_us
+ response.execution_time_us
+ response.state_root_time_us
);

Ok(())
}

#[tokio::test]
async fn meter_block_single_transaction() -> eyre::Result<()> {
let harness = TestHarness::new().await?;

let to = Address::random();
let signed_tx = TransactionBuilder::default()
.signer(Account::Alice.signer_b256())
.chain_id(harness.chain_id())
.nonce(0)
.to(to)
.value(1_000)
.gas_limit(21_000)
.max_fee_per_gas(10)
.max_priority_fee_per_gas(1)
.into_eip1559();

let tx = OpTransactionSigned::Eip1559(
signed_tx.as_eip1559().expect("eip1559 transaction").clone(),
);
let tx_hash = tx.tx_hash();

let block = create_block_with_transactions(&harness, vec![tx]);

let response = meter_block(harness.blockchain_provider(), harness.chain_spec(), &block)?;

assert_eq!(response.block_hash, block.header().hash_slow());
assert_eq!(response.block_number, block.header().number());
assert_eq!(response.transactions.len(), 1);

let metered_tx = &response.transactions[0];
assert_eq!(metered_tx.tx_hash, tx_hash);
assert_eq!(metered_tx.gas_used, 21_000);
assert!(metered_tx.execution_time_us > 0, "transaction execution time should be non-zero");

assert!(response.signer_recovery_time_us > 0, "signer recovery should take time");
assert!(response.execution_time_us > 0);
assert!(response.state_root_time_us > 0);
assert_eq!(
response.total_time_us,
response.signer_recovery_time_us
+ response.execution_time_us
+ response.state_root_time_us
);

Ok(())
}

#[tokio::test]
async fn meter_block_multiple_transactions() -> eyre::Result<()> {
let harness = TestHarness::new().await?;

let to_1 = Address::random();
let to_2 = Address::random();

// Create first transaction from Alice
let signed_tx_1 = TransactionBuilder::default()
.signer(Account::Alice.signer_b256())
.chain_id(harness.chain_id())
.nonce(0)
.to(to_1)
.value(1_000)
.gas_limit(21_000)
.max_fee_per_gas(10)
.max_priority_fee_per_gas(1)
.into_eip1559();

let tx_1 = OpTransactionSigned::Eip1559(
signed_tx_1.as_eip1559().expect("eip1559 transaction").clone(),
);
let tx_hash_1 = tx_1.tx_hash();

// Create second transaction from Bob
let signed_tx_2 = TransactionBuilder::default()
.signer(Account::Bob.signer_b256())
.chain_id(harness.chain_id())
.nonce(0)
.to(to_2)
.value(2_000)
.gas_limit(21_000)
.max_fee_per_gas(15)
.max_priority_fee_per_gas(2)
.into_eip1559();

let tx_2 = OpTransactionSigned::Eip1559(
signed_tx_2.as_eip1559().expect("eip1559 transaction").clone(),
);
let tx_hash_2 = tx_2.tx_hash();

let block = create_block_with_transactions(&harness, vec![tx_1, tx_2]);

let response = meter_block(harness.blockchain_provider(), harness.chain_spec(), &block)?;

assert_eq!(response.block_hash, block.header().hash_slow());
assert_eq!(response.block_number, block.header().number());
assert_eq!(response.transactions.len(), 2);

// Check first transaction
let metered_tx_1 = &response.transactions[0];
assert_eq!(metered_tx_1.tx_hash, tx_hash_1);
assert_eq!(metered_tx_1.gas_used, 21_000);
assert!(metered_tx_1.execution_time_us > 0);

// Check second transaction
let metered_tx_2 = &response.transactions[1];
assert_eq!(metered_tx_2.tx_hash, tx_hash_2);
assert_eq!(metered_tx_2.gas_used, 21_000);
assert!(metered_tx_2.execution_time_us > 0);

// Check aggregate times
assert!(response.signer_recovery_time_us > 0, "signer recovery should take time");
assert!(response.execution_time_us > 0);
assert!(response.state_root_time_us > 0);
assert_eq!(
response.total_time_us,
response.signer_recovery_time_us
+ response.execution_time_us
+ response.state_root_time_us
);

// Ensure individual transaction times are consistent with total
let individual_times: u128 =
response.transactions.iter().map(|t| t.execution_time_us).sum();
assert!(
individual_times <= response.execution_time_us,
"sum of individual times should not exceed total (due to EVM overhead)"
);

Ok(())
}

#[tokio::test]
async fn meter_block_timing_consistency() -> eyre::Result<()> {
let harness = TestHarness::new().await?;

// Create a block with one transaction
let signed_tx = TransactionBuilder::default()
.signer(Account::Alice.signer_b256())
.chain_id(harness.chain_id())
.nonce(0)
.to(Address::random())
.value(1_000)
.gas_limit(21_000)
.max_fee_per_gas(10)
.max_priority_fee_per_gas(1)
.into_eip1559();

let tx = OpTransactionSigned::Eip1559(
signed_tx.as_eip1559().expect("eip1559 transaction").clone(),
);

let block = create_block_with_transactions(&harness, vec![tx]);

let response = meter_block(harness.blockchain_provider(), harness.chain_spec(), &block)?;

// Verify timing invariants
assert!(response.signer_recovery_time_us > 0, "signer recovery time must be positive");
assert!(response.execution_time_us > 0, "execution time must be positive");
assert!(response.state_root_time_us > 0, "state root time must be positive");
assert_eq!(
response.total_time_us,
response.signer_recovery_time_us
+ response.execution_time_us
+ response.state_root_time_us,
"total time must equal signer recovery + execution + state root times"
);

Ok(())
}

// ============================================================================
// Error Path Tests
// ============================================================================

#[tokio::test]
async fn meter_block_parent_header_not_found() -> eyre::Result<()> {
let harness = TestHarness::new().await?;
let latest = harness.latest_block();

// Create a block that references a non-existent parent
let fake_parent_hash = B256::random();
let header = Header {
parent_hash: fake_parent_hash, // This parent doesn't exist
number: 999,
timestamp: latest.timestamp() + 2,
gas_limit: 30_000_000,
beneficiary: Address::random(),
base_fee_per_gas: Some(1),
parent_beacon_block_root: Some(B256::ZERO),
..Default::default()
};

let body = OpBlockBody { transactions: vec![], ommers: vec![], withdrawals: None };
let block = OpBlock::new(header, body);

let result = meter_block(harness.blockchain_provider(), harness.chain_spec(), &block);

assert!(result.is_err(), "should fail when parent header is not found");
let err = result.unwrap_err();
let err_str = err.to_string();
assert!(
err_str.contains("Parent header not found") || err_str.contains("not found"),
"error should indicate parent header not found: {}",
err_str
);

Ok(())
}

#[tokio::test]
async fn meter_block_invalid_transaction_signature() -> eyre::Result<()> {
let harness = TestHarness::new().await?;

// Create a transaction with an invalid signature
let tx = TxEip1559 {
chain_id: harness.chain_id(),
nonce: 0,
gas_limit: 21_000,
max_fee_per_gas: 10,
max_priority_fee_per_gas: 1,
to: alloy_primitives::TxKind::Call(Address::random()),
value: alloy_primitives::U256::from(1000),
access_list: Default::default(),
input: Default::default(),
};

// Create a signature with invalid values (all zeros is invalid for secp256k1)
let invalid_signature =
Signature::new(alloy_primitives::U256::ZERO, alloy_primitives::U256::ZERO, false);

let signed_tx =
alloy_consensus::Signed::new_unchecked(tx, invalid_signature, B256::random());
let op_tx = OpTransactionSigned::Eip1559(signed_tx);

let block = create_block_with_transactions(&harness, vec![op_tx]);

let result = meter_block(harness.blockchain_provider(), harness.chain_spec(), &block);

assert!(result.is_err(), "should fail when transaction has invalid signature");
let err = result.unwrap_err();
let err_str = err.to_string();
assert!(
err_str.contains("recover signer") || err_str.contains("signature"),
"error should indicate signer recovery failure: {}",
err_str
);

Ok(())
}
}
Loading