Skip to content

Commit 9331cfe

Browse files
committed
spike userops
1 parent 25d22cb commit 9331cfe

File tree

7 files changed

+934
-14
lines changed

7 files changed

+934
-14
lines changed

Cargo.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/builder/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,9 @@ rblib.workspace = true
1212
reth-optimism-cli.workspace = true
1313
reth-optimism-node.workspace = true
1414
reth-optimism-rpc.workspace = true
15+
account-abstraction-core.workspace = true
16+
alloy-primitives.workspace = true
17+
alloy-sol-types.workspace = true
18+
alloy-rpc-types.workspace = true
19+
anyhow.workspace = true
20+
serde.workspace = true

crates/builder/README.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# TIPS Builder - ERC-4337 UserOp Support
2+
3+
This builder integrates ERC-4337 UserOperations with rblib, following the enshrining pattern from [base-op-rbuilder#1](https://github.com/shamit05/base-op-rbuilder/pull/1).
4+
5+
## Running Tests
6+
7+
```bash
8+
cargo test -p tips-builder
9+
```
10+
11+
All tests verify the UserOperation bundling functionality, including midpoint insertion behavior.
12+
13+
## Architecture
14+
15+
### Components
16+
17+
**`userops.rs`** - `UserOperationOrder`
18+
- Implements `OrderpoolOrder` trait for rblib compatibility
19+
- Wraps `UserOperationRequest` from `account-abstraction-core`
20+
- Supports nonce-based conflict resolution
21+
- Works with both v0.6 and v0.7 UserOperations
22+
23+
**`bundle.rs`** - `UserOpBundle`
24+
- Implements `Bundle<Optimism>` trait for rblib
25+
- Creates EntryPoint `handleOps` calldata for bundling UserOps
26+
- Supports bundler transaction positioning (Start/Middle/End)
27+
- Handles both PackedUserOperation (v0.7) and UserOperation (v0.6)
28+
29+
### Key Feature: Bundler Transaction in Middle
30+
31+
The `UserOpBundle` generates a single transaction that calls `EntryPoint.handleOps(ops[], beneficiary)` with all UserOperations. This bundler transaction can be positioned:
32+
33+
- **Start**: Before all other transactions
34+
- **Middle** (default): In the middle of other transactions in the block
35+
- **End**: After all other transactions
36+
37+
## Usage
38+
39+
### Creating a UserOp Bundle
40+
41+
```rust
42+
use tips_builder::{UserOpBundle, BundlerPosition};
43+
use alloy_primitives::address;
44+
45+
let beneficiary = address!("0x2222...");
46+
let entry_point = address!("0x0000000071727De22E5E9d8BAf0edAc6f37da032");
47+
48+
// Create bundle with UserOps
49+
let bundle = UserOpBundle::new(beneficiary)
50+
.with_user_op(user_op_request_1)
51+
.with_user_op(user_op_request_2)
52+
.with_user_op(user_op_request_3)
53+
.with_position(BundlerPosition::Middle);
54+
55+
// Generate EntryPoint.handleOps calldata
56+
let calldata = bundle.build_bundler_calldata();
57+
```
58+
59+
### Transaction Ordering
60+
61+
When `BundlerPosition::Middle` is set and the bundle is applied in a block:
62+
63+
```
64+
tx1 (regular transaction)
65+
tx2 (regular transaction)
66+
→ BUNDLER TX calling handleOps([userOp1, userOp2, userOp3], beneficiary)
67+
tx3 (regular transaction)
68+
tx4 (regular transaction)
69+
```
70+
71+
The bundler transaction processes all UserOps atomically in a single transaction.
72+
73+
### Integration with rblib OrderPool
74+
75+
```rust
76+
use tips_builder::UserOperationOrder;
77+
78+
// UserOps can be added to the OrderPool just like transactions
79+
let user_op_order = UserOperationOrder::new(user_op_request)?;
80+
pool.add_order(user_op_order);
81+
```
82+
83+
## Implementation Details
84+
85+
### UserOperation to Bundle Transaction
86+
87+
The `build_bundler_calldata()` method:
88+
1. Converts `UserOperationRequest``PackedUserOperation` (v0.7 format)
89+
2. Packs gas limits into `accountGasLimits` (bytes32)
90+
3. Packs fees into `gasFees` (bytes32)
91+
4. Encodes as `handleOps(PackedUserOperation[], address)` calldata
92+
93+
### Bundle Trait Implementation
94+
95+
`UserOpBundle` implements rblib's `Bundle` trait:
96+
- `transactions()`: Returns the bundler transaction if set
97+
- `hash()`: Keccak256 of all UserOp hashes + bundler tx + beneficiary
98+
- `without_transaction()`: Removes specific transactions
99+
- `validate_post_execution()`: Post-execution validation hook
100+
101+
## Enshrining Pattern
102+
103+
Based on the pattern from op-rbuilder where the bundler transaction is "enshrined" into the block:
104+
105+
1. **Mempool** provides top UserOps sorted by gas price
106+
2. **Bundler** creates a single transaction calling `EntryPoint.handleOps`
107+
3. **Builder** positions this transaction in the middle of the block
108+
4. **EntryPoint** atomically executes all UserOps in one transaction
109+
110+
This ensures:
111+
- Gas-efficient bundling (one transaction for many UserOps)
112+
- Atomic execution (all-or-nothing)
113+
- MEV protection through positioning
114+
- Beneficiary receives all bundle fees

crates/builder/src/bundle.rs

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
use account_abstraction_core::types::{UserOperationRequest, VersionedUserOperation};
2+
use alloy_primitives::{Address, B256, Bytes, Keccak256, TxHash};
3+
use alloy_sol_types::{SolCall, sol};
4+
use rblib::{prelude::*, reth};
5+
use reth::{primitives::Recovered, revm::db::BundleState};
6+
use serde::{Deserialize, Serialize};
7+
8+
sol! {
9+
interface IEntryPointV07 {
10+
function handleOps(
11+
PackedUserOperation[] calldata ops,
12+
address payable beneficiary
13+
) external;
14+
}
15+
16+
struct PackedUserOperation {
17+
address sender;
18+
uint256 nonce;
19+
bytes initCode;
20+
bytes callData;
21+
bytes32 accountGasLimits;
22+
uint256 preVerificationGas;
23+
bytes32 gasFees;
24+
bytes paymasterAndData;
25+
bytes signature;
26+
}
27+
}
28+
29+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
30+
pub struct UserOpBundle {
31+
pub user_ops: Vec<UserOperationRequest>,
32+
pub entry_point: Address,
33+
pub beneficiary: Address,
34+
pub reverting_txs: Vec<TxHash>,
35+
pub dropping_txs: Vec<TxHash>,
36+
}
37+
38+
impl UserOpBundle {
39+
pub fn new(entry_point: Address, beneficiary: Address) -> Self {
40+
Self {
41+
user_ops: Vec::new(),
42+
entry_point,
43+
beneficiary,
44+
reverting_txs: Vec::new(),
45+
dropping_txs: Vec::new(),
46+
}
47+
}
48+
49+
pub fn with_user_op(mut self, user_op: UserOperationRequest) -> Self {
50+
self.user_ops.push(user_op);
51+
self
52+
}
53+
54+
pub fn build_handleops_calldata(&self) -> Option<Bytes> {
55+
if self.user_ops.is_empty() {
56+
return None;
57+
}
58+
59+
let packed_ops: Vec<PackedUserOperation> = self
60+
.user_ops
61+
.iter()
62+
.filter_map(|req| match &req.user_operation {
63+
VersionedUserOperation::PackedUserOperation(op) => {
64+
let init_code = if let Some(factory) = op.factory {
65+
let mut ic = factory.to_vec();
66+
ic.extend_from_slice(&op.factory_data.clone().unwrap_or_default());
67+
Bytes::from(ic)
68+
} else {
69+
Bytes::new()
70+
};
71+
72+
let paymaster_and_data = if let Some(paymaster) = op.paymaster {
73+
let mut pd = paymaster.to_vec();
74+
let pvgl: [u8; 16] = op
75+
.paymaster_verification_gas_limit
76+
.unwrap_or_default()
77+
.to::<u128>()
78+
.to_be_bytes();
79+
let pogl: [u8; 16] = op
80+
.paymaster_post_op_gas_limit
81+
.unwrap_or_default()
82+
.to::<u128>()
83+
.to_be_bytes();
84+
pd.extend_from_slice(&pvgl);
85+
pd.extend_from_slice(&pogl);
86+
pd.extend_from_slice(&op.paymaster_data.clone().unwrap_or_default());
87+
Bytes::from(pd)
88+
} else {
89+
Bytes::new()
90+
};
91+
92+
let vgl_bytes: [u8; 16] = op.verification_gas_limit.to::<u128>().to_be_bytes();
93+
let cgl_bytes: [u8; 16] = op.call_gas_limit.to::<u128>().to_be_bytes();
94+
let mut account_gas_limits = [0u8; 32];
95+
account_gas_limits[..16].copy_from_slice(&vgl_bytes);
96+
account_gas_limits[16..].copy_from_slice(&cgl_bytes);
97+
98+
let mpfpg_bytes: [u8; 16] =
99+
op.max_priority_fee_per_gas.to::<u128>().to_be_bytes();
100+
let mfpg_bytes: [u8; 16] = op.max_fee_per_gas.to::<u128>().to_be_bytes();
101+
let mut gas_fees = [0u8; 32];
102+
gas_fees[..16].copy_from_slice(&mpfpg_bytes);
103+
gas_fees[16..].copy_from_slice(&mfpg_bytes);
104+
105+
Some(PackedUserOperation {
106+
sender: op.sender,
107+
nonce: op.nonce,
108+
initCode: init_code,
109+
callData: op.call_data.clone(),
110+
accountGasLimits: account_gas_limits.into(),
111+
preVerificationGas: op.pre_verification_gas,
112+
gasFees: gas_fees.into(),
113+
paymasterAndData: paymaster_and_data,
114+
signature: op.signature.clone(),
115+
})
116+
}
117+
_ => None,
118+
})
119+
.collect();
120+
121+
if packed_ops.is_empty() {
122+
return None;
123+
}
124+
125+
let call = IEntryPointV07::handleOpsCall {
126+
ops: packed_ops,
127+
beneficiary: self.beneficiary,
128+
};
129+
130+
Some(call.abi_encode().into())
131+
}
132+
133+
pub fn create_bundle_transaction(
134+
&self,
135+
bundler_address: Address,
136+
nonce: u64,
137+
chain_id: u64,
138+
base_fee: u128,
139+
) -> Option<Recovered<types::Transaction<Optimism>>> {
140+
use rblib::alloy::consensus::{SignableTransaction, Signed, TxEip1559};
141+
use rblib::alloy::primitives::{Signature, TxKind, U256};
142+
143+
let calldata = self.build_handleops_calldata()?;
144+
145+
let max_fee = base_fee.saturating_mul(2);
146+
let max_priority_fee = 1_000_000u128;
147+
148+
let tx_eip1559 = TxEip1559 {
149+
chain_id,
150+
nonce,
151+
gas_limit: 5_000_000,
152+
max_fee_per_gas: max_fee,
153+
max_priority_fee_per_gas: max_priority_fee,
154+
to: TxKind::Call(self.entry_point),
155+
value: U256::ZERO,
156+
access_list: Default::default(),
157+
input: calldata,
158+
};
159+
160+
let signature = Signature::from_scalars_and_parity(B256::ZERO, B256::ZERO, false);
161+
162+
let hash = tx_eip1559.signature_hash();
163+
let signed_tx = Signed::new_unchecked(tx_eip1559, signature, hash);
164+
let tx = types::Transaction::<Optimism>::Eip1559(signed_tx);
165+
166+
Some(Recovered::new_unchecked(tx, bundler_address))
167+
}
168+
}
169+
170+
impl Default for UserOpBundle {
171+
fn default() -> Self {
172+
Self::new(Address::ZERO, Address::ZERO)
173+
}
174+
}
175+
176+
impl Bundle<Optimism> for UserOpBundle {
177+
type PostExecutionError = UserOpBundleError;
178+
179+
fn transactions(&self) -> &[Recovered<types::Transaction<Optimism>>] {
180+
&[]
181+
}
182+
183+
fn without_transaction(self, tx: TxHash) -> Self {
184+
Self {
185+
user_ops: self.user_ops,
186+
entry_point: self.entry_point,
187+
beneficiary: self.beneficiary,
188+
reverting_txs: self
189+
.reverting_txs
190+
.into_iter()
191+
.filter(|t| *t != tx)
192+
.collect(),
193+
dropping_txs: self.dropping_txs.into_iter().filter(|t| *t != tx).collect(),
194+
}
195+
}
196+
197+
fn is_eligible(&self, _: &BlockContext<Optimism>, _: &()) -> Eligibility {
198+
Eligibility::Eligible
199+
}
200+
201+
fn is_allowed_to_fail(&self, tx: &TxHash) -> bool {
202+
self.reverting_txs.contains(tx)
203+
}
204+
205+
fn is_optional(&self, tx: &TxHash) -> bool {
206+
self.dropping_txs.contains(tx)
207+
}
208+
209+
fn validate_post_execution(
210+
&self,
211+
_state: &BundleState,
212+
_block: &BlockContext<Optimism>,
213+
) -> Result<(), Self::PostExecutionError> {
214+
Ok(())
215+
}
216+
217+
fn hash(&self) -> B256 {
218+
let mut hasher = Keccak256::default();
219+
220+
for user_op in &self.user_ops {
221+
if let Ok(hash) = user_op.hash() {
222+
hasher.update(hash);
223+
}
224+
}
225+
226+
hasher.update(self.entry_point);
227+
hasher.update(self.beneficiary);
228+
229+
for tx in &self.reverting_txs {
230+
hasher.update(tx);
231+
}
232+
233+
for tx in &self.dropping_txs {
234+
hasher.update(tx);
235+
}
236+
237+
hasher.finalize()
238+
}
239+
}
240+
241+
#[derive(Debug)]
242+
pub enum UserOpBundleError {
243+
InvalidUserOp,
244+
}
245+
246+
impl core::fmt::Display for UserOpBundleError {
247+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
248+
match self {
249+
Self::InvalidUserOp => write!(f, "Invalid UserOperation"),
250+
}
251+
}
252+
}
253+
254+
impl core::error::Error for UserOpBundleError {}

0 commit comments

Comments
 (0)