Skip to content

Commit

Permalink
feat: implement bundler sponsorship
Browse files Browse the repository at this point in the history
  • Loading branch information
dancoombs committed Feb 25, 2025
1 parent 9cddce7 commit 9287b8b
Show file tree
Hide file tree
Showing 13 changed files with 222 additions and 28 deletions.
48 changes: 43 additions & 5 deletions crates/builder/src/bundle_proposer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ use rundler_types::{
pool::{Pool, PoolOperation, SimulationViolation},
proxy::SubmissionProxy,
Entity, EntityInfo, EntityInfos, EntityType, EntityUpdate, EntityUpdateType, GasFees,
Timestamp, UserOperation, UserOperationVariant, UserOpsPerAggregator, ValidationRevert,
BUNDLE_BYTE_OVERHEAD, TIME_RANGE_BUFFER, USER_OP_OFFSET_WORD_SIZE,
Timestamp, UserOperation, UserOperationVariant, UserOpsPerAggregator, ValidTimeRange,
ValidationRevert, BUNDLE_BYTE_OVERHEAD, TIME_RANGE_BUFFER, USER_OP_OFFSET_WORD_SIZE,
};
use rundler_utils::{emit::WithEntryPoint, guard_timer::CustomTimerGuard, math};
use tokio::{sync::broadcast, try_join};
Expand Down Expand Up @@ -395,8 +395,10 @@ where
}

// filter by fees
if op.uo.max_fee_per_gas() < required_max_fee_per_gas
|| op.uo.max_priority_fee_per_gas() < required_max_priority_fee_per_gas
if (op.uo.max_fee_per_gas() < required_max_fee_per_gas
|| op.uo.max_priority_fee_per_gas() < required_max_priority_fee_per_gas)
&& op.perms.bundler_sponsorship.is_none()
// skip if bundler sponsored
{
self.emit(BuilderEvent::skipped_op(
self.builder_tag.clone(),
Expand Down Expand Up @@ -475,7 +477,7 @@ where
required_pvg = math::percent_ceil(required_pvg, pct);
}

if op.uo.pre_verification_gas() < required_pvg {
if op.uo.pre_verification_gas() < required_pvg && op.perms.bundler_sponsorship.is_none() {
self.emit(BuilderEvent::skipped_op(
self.builder_tag.clone(),
op_hash,
Expand All @@ -492,6 +494,25 @@ where
return None;
}

// Check total gas cost and time for bundler sponsorship
if let Some(bundler_sponsorship) = &op.perms.bundler_sponsorship {
let total_gas_cost = U256::from(
op.uo
.gas_limit(&self.settings.chain_spec, Some(bundle_size)),
) * U256::from(required_op_fees.gas_price(base_fee));
if total_gas_cost > bundler_sponsorship.max_cost {
self.emit(BuilderEvent::skipped_op(
self.builder_tag.clone(),
op_hash,
SkipReason::InsufficientSponsorshipCost {
max_cost: bundler_sponsorship.max_cost,
actual_cost: total_gas_cost,
},
));
return None;
}
}

Some(op)
}

Expand Down Expand Up @@ -598,6 +619,23 @@ where
));
context.rejected_ops.push((op.into(), po.entity_infos));
continue;
} else if let Some(bundler_sponsorship) = &po.perms.bundler_sponsorship {
let valid_time_range =
ValidTimeRange::from_genesis(bundler_sponsorship.valid_until.into());
if !simulation
.valid_time_range
.contains(Timestamp::now(), TIME_RANGE_BUFFER)
{
self.emit(BuilderEvent::skipped_op(
self.builder_tag.clone(),
op.hash(),
SkipReason::InvalidTimeRange {
valid_range: valid_time_range,
},
));
context.rejected_ops.push((op.into(), po.entity_infos));
continue;
}
}

// Limit by transaction size
Expand Down
4 changes: 3 additions & 1 deletion crates/builder/src/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

use std::{fmt::Display, sync::Arc};

use alloy_primitives::{Address, B256};
use alloy_primitives::{Address, B256, U256};
use rundler_provider::TransactionRequest;
use rundler_sim::SimulationError;
use rundler_types::{GasFees, ValidTimeRange};
Expand Down Expand Up @@ -168,6 +168,8 @@ pub enum SkipReason {
required_pvg: u128,
actual_pvg: u128,
},
/// Insufficient sponsorship cost for the operation
InsufficientSponsorshipCost { max_cost: U256, actual_cost: U256 },
/// Bundle ran out of space by gas limit to include the operation
GasLimit,
/// Expected storage conflict
Expand Down
6 changes: 6 additions & 0 deletions crates/pool/proto/op_pool/op_pool.proto
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ message UserOperationPermissions {
optional uint64 max_allowed_in_pool_for_sender = 2;
optional uint32 underpriced_accept_pct = 3;
optional uint32 underpriced_bundle_pct = 4;
BundlerSponsorship bundler_sponsorship = 5;
}

message BundlerSponsorship {
bytes max_cost = 1;
uint64 valid_until = 2;
}

// Protocol Buffer representation of an 7702 authorization tuple. See the official
Expand Down
35 changes: 32 additions & 3 deletions crates/pool/src/mempool/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,37 @@ where
},
});
expired.push(*hash);
continue;
} else if op
.po
.perms
.bundler_sponsorship
.as_ref()
.is_some_and(|s| Timestamp::from(s.valid_until) < block_timestamp)
{
events.push(PoolEvent::RemovedOp {
op_hash: *hash,
reason: OpRemovalReason::Expired {
valid_until: op
.po
.perms
.bundler_sponsorship
.as_ref()
.unwrap()
.valid_until
.into(),
},
});
expired.push(*hash);
continue;
}

// check for eligibility
if self.da_gas_oracle.is_some() && block_da_data.is_some() {
if self.da_gas_oracle.is_some()
&& block_da_data.is_some()
&& op.po.perms.bundler_sponsorship.is_none()
// skip if bundler sponsored
{
// TODO(bundle): assuming a bundle size of 1
let bundle_size = 1;

Expand Down Expand Up @@ -321,8 +348,10 @@ where
self.best.insert(op.clone());

// Check candidate status
if op.uo().max_fee_per_gas() < gas_fees.uo_fees.max_fee_per_gas
|| op.uo().max_priority_fee_per_gas() < gas_fees.uo_fees.max_priority_fee_per_gas
if (op.uo().max_fee_per_gas() < gas_fees.uo_fees.max_fee_per_gas
|| op.uo().max_priority_fee_per_gas() < gas_fees.uo_fees.max_priority_fee_per_gas)
&& op.po.perms.bundler_sponsorship.is_none()
// skip if bundler sponsored
{
// don't mark as ineligible, but also not a candidate
continue;
Expand Down
46 changes: 38 additions & 8 deletions crates/pool/src/server/remote/protos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ use rundler_types::{
Reputation as PoolReputation, ReputationStatus as PoolReputationStatus,
StakeStatus as RundlerStakeStatus,
},
v0_6, v0_7, Entity as RundlerEntity, EntityInfos, EntityType as RundlerEntityType,
EntityUpdate as RundlerEntityUpdate, EntityUpdateType as RundlerEntityUpdateType,
StakeInfo as RundlerStakeInfo, UserOperation as _,
v0_6, v0_7, BundlerSponsorship as RundlerBundlerSponsorship, Entity as RundlerEntity,
EntityInfos, EntityType as RundlerEntityType, EntityUpdate as RundlerEntityUpdate,
EntityUpdateType as RundlerEntityUpdateType, StakeInfo as RundlerStakeInfo, UserOperation as _,
UserOperationPermissions as RundlerUserOperationPermissions, UserOperationVariant,
ValidTimeRange,
};
Expand Down Expand Up @@ -525,7 +525,10 @@ impl TryUoFromProto<MempoolOp> for PoolOperation {
.context("DA gas data should be set")?
.try_into()?,
filter_id,
perms: op.permissions.context("Permissions should be set")?.into(),
perms: op
.permissions
.context("Permissions should be set")?
.try_into()?,
})
}
}
Expand Down Expand Up @@ -572,16 +575,22 @@ impl From<PoolPaymasterMetadata> for PaymasterBalance {
}
}

impl From<UserOperationPermissions> for RundlerUserOperationPermissions {
fn from(permissions: UserOperationPermissions) -> Self {
Self {
impl TryFrom<UserOperationPermissions> for RundlerUserOperationPermissions {
type Error = ConversionError;

fn try_from(permissions: UserOperationPermissions) -> Result<Self, Self::Error> {
Ok(Self {
trusted: permissions.trusted,
max_allowed_in_pool_for_sender: permissions
.max_allowed_in_pool_for_sender
.map(|c| c as usize),
underpriced_accept_pct: permissions.underpriced_accept_pct,
underpriced_bundle_pct: permissions.underpriced_bundle_pct,
}
bundler_sponsorship: permissions
.bundler_sponsorship
.map(|s| s.try_into())
.transpose()?,
})
}
}

Expand All @@ -594,6 +603,27 @@ impl From<RundlerUserOperationPermissions> for UserOperationPermissions {
.map(|c| c as u64),
underpriced_accept_pct: permissions.underpriced_accept_pct,
underpriced_bundle_pct: permissions.underpriced_bundle_pct,
bundler_sponsorship: permissions.bundler_sponsorship.map(|s| s.into()),
}
}
}

impl TryFrom<BundlerSponsorship> for RundlerBundlerSponsorship {
type Error = ConversionError;

fn try_from(sponsorship: BundlerSponsorship) -> Result<Self, Self::Error> {
Ok(Self {
max_cost: from_bytes(&sponsorship.max_cost)?,
valid_until: sponsorship.valid_until,
})
}
}

impl From<RundlerBundlerSponsorship> for BundlerSponsorship {
fn from(sponsorship: RundlerBundlerSponsorship) -> Self {
Self {
max_cost: sponsorship.max_cost.to_proto_bytes(),
valid_until: sponsorship.valid_until,
}
}
}
7 changes: 6 additions & 1 deletion crates/pool/src/server/remote/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,12 @@ impl OpPool for OpPoolImpl {
let permissions = req
.permissions
.ok_or_else(|| Status::invalid_argument("Permissions are required in AddOpRequest"))?
.into();
.try_into()
.map_err(|e| {
Status::invalid_argument(format!(
"Failed to convert to UserOperationPermissions: {e}"
))
})?;

let resp = match self.local_pool.add_op(uo, permissions).await {
Ok(hash) => AddOpResponse {
Expand Down
23 changes: 23 additions & 0 deletions crates/rpc/src/eth/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,29 @@ where
)));
}

if permissions.bundler_sponsorship.is_some() {
if op.max_fee_per_gas() != 0 {
return Err(EthRpcError::InvalidParams(
"Bundler sponsorship requires max fee per gas to be 0".to_string(),
));
}
if op.max_priority_fee_per_gas() != 0 {
return Err(EthRpcError::InvalidParams(
"Bundler sponsorship requires max priority fee per gas to be 0".to_string(),
));
}
if op.pre_verification_gas() != 0 {
return Err(EthRpcError::InvalidParams(
"Bundler sponsorship requires pre-verification gas to be 0".to_string(),
));
}
if op.paymaster().is_some() {
return Err(EthRpcError::InvalidParams(
"Bundler sponsorship requires paymaster to be empty".to_string(),
));
}
}

self.router.check_and_get_route(&entry_point, &op)?;

self.pool
Expand Down
28 changes: 26 additions & 2 deletions crates/rpc/src/types/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
// You should have received a copy of the GNU General Public License along with Rundler.
// If not, see https://www.gnu.org/licenses/.

use alloy_primitives::U64;
use rundler_types::{chain::ChainSpec, UserOperationPermissions};
use alloy_primitives::{U256, U64};
use rundler_types::{chain::ChainSpec, BundlerSponsorship, UserOperationPermissions};
use serde::{Deserialize, Serialize};

use super::FromRpc;
Expand All @@ -33,6 +33,18 @@ pub(crate) struct RpcUserOperationPermissions {
/// The allowed percentage of fees underpriced that is bundled
#[serde(default)]
pub(crate) underpriced_bundle_pct: Option<U64>,
/// Bundler sponsorship settings
#[serde(default)]
pub(crate) bundler_sponsorship: Option<RpcBundlerSponsorship>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RpcBundlerSponsorship {
/// The maximum cost the bundler is willing to pay for the user operation
pub(crate) max_cost: U256,
/// The valid until timestamp of the sponsorship
pub(crate) valid_until: U64,
}

impl FromRpc<RpcUserOperationPermissions> for UserOperationPermissions {
Expand All @@ -42,6 +54,18 @@ impl FromRpc<RpcUserOperationPermissions> for UserOperationPermissions {
max_allowed_in_pool_for_sender: rpc.max_allowed_in_pool_for_sender.map(|c| c.to()),
underpriced_accept_pct: rpc.underpriced_accept_pct.map(|c| c.to()),
underpriced_bundle_pct: rpc.underpriced_bundle_pct.map(|c| c.to()),
bundler_sponsorship: rpc
.bundler_sponsorship
.map(|c| BundlerSponsorship::from_rpc(c, _chain_spec)),
}
}
}

impl FromRpc<RpcBundlerSponsorship> for BundlerSponsorship {
fn from_rpc(rpc: RpcBundlerSponsorship, _chain_spec: &ChainSpec) -> Self {
BundlerSponsorship {
max_cost: rpc.max_cost,
valid_until: rpc.valid_until.to(),
}
}
}
19 changes: 13 additions & 6 deletions crates/sim/src/precheck.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,19 @@ where
max_total_execution_gas,
))
}
if op.call_gas_limit() < MIN_CALL_GAS_LIMIT {
violations.push(PrecheckViolation::CallGasLimitTooLow(
op.call_gas_limit(),
MIN_CALL_GAS_LIMIT,
));
}

// If the UO is bundler sponsored, skip the fee checks
if perms.bundler_sponsorship.is_some() {
return violations;
}

// NOTE: only fee checks below this short circuit

// if preVerificationGas is dynamic, then allow for the percentage buffer
// and check if the preVerificationGas is at least the minimum.
Expand Down Expand Up @@ -348,12 +361,6 @@ where
));
}

if op.call_gas_limit() < MIN_CALL_GAS_LIMIT {
violations.push(PrecheckViolation::CallGasLimitTooLow(
op.call_gas_limit(),
MIN_CALL_GAS_LIMIT,
));
}
violations
}

Expand Down
11 changes: 10 additions & 1 deletion crates/types/src/gas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
// You should have received a copy of the GNU General Public License along with Rundler.
// If not, see https://www.gnu.org/licenses/.

use rundler_utils::math;
use std::cmp;

use rundler_utils::math;
/// Gas fees for a user operation or transaction
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct GasFees {
Expand All @@ -33,4 +34,12 @@ impl GasFees {
),
}
}

/// Get the gas price from these fees given a base fee
pub fn gas_price(self, base_fee: u128) -> u128 {
cmp::min(
self.max_fee_per_gas,
base_fee + self.max_priority_fee_per_gas,
)
}
}
Loading

0 comments on commit 9287b8b

Please sign in to comment.