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
40 changes: 39 additions & 1 deletion bedrock/src/primitives/contracts.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use crate::primitives::PrimitiveError;
use crate::transactions::rpc::SponsorUserOperationResponse;
use crate::transactions::rpc::{
PmSponsorUserOperationResponse, SponsorUserOperationResponse,
};
use alloy::hex::FromHex;
use alloy::primitives::{aliases::U48, keccak256, Address, Bytes, FixedBytes, U128};
use alloy::sol;
Expand Down Expand Up @@ -276,6 +278,42 @@ impl UserOperation {

self
}

/// Merges a V2 `pm_sponsorUserOperation` response into the `UserOperation`.
///
/// Every field on the response is written through unconditionally — the
/// response is the source of truth for the post-merge `UserOp`. Gas fields
/// always carry values on both response shapes; paymaster fields are
/// `Some(...)` only on the self-sponsored shape, `None` on the
/// protocol-sponsored shape. A `None` clears any prior paymaster data on
/// the input `UserOp`, so the protocol-sponsored merge cannot accidentally
/// preserve stale paymaster fields from earlier mutations.
///
/// See `bedrock/src/transactions/transaction.md` for the wire contract.
#[must_use]
pub fn with_sponsorship_data(
mut self,
sponsor_response: &PmSponsorUserOperationResponse,
) -> Self {
// Gas fields are always populated on both response shapes.
self.pre_verification_gas = sponsor_response.pre_verification_gas;
self.verification_gas_limit = sponsor_response.verification_gas_limit;
self.call_gas_limit = sponsor_response.call_gas_limit;
self.max_fee_per_gas = sponsor_response.max_fee_per_gas;
self.max_priority_fee_per_gas = sponsor_response.max_priority_fee_per_gas;

// Paymaster fields: overwrite unconditionally. `None` on the
// protocol-sponsored shape clears any prior paymaster data, mirroring
// V1's `with_paymaster_data` so the two merge paths stay symmetric.
self.paymaster = sponsor_response.paymaster;
self.paymaster_data
.clone_from(&sponsor_response.paymaster_data);
self.paymaster_verification_gas_limit =
sponsor_response.paymaster_verification_gas_limit;
self.paymaster_post_op_gas_limit = sponsor_response.paymaster_post_op_gas_limit;

self
}
}

impl EncodedSafeOpStruct {
Expand Down
200 changes: 199 additions & 1 deletion bedrock/src/smart_account/transaction_4337.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use crate::primitives::contracts::{EncodedSafeOpStruct, UserOperation};
use crate::primitives::{Network, PrimitiveError};
use crate::smart_account::{SafeSmartAccount, SafeSmartAccountSigner};
use crate::transactions::rpc::{RpcError, RpcProviderName};
use crate::transactions::rpc::{RpcError, RpcProviderName, SponsorshipContext};

use alloy::primitives::{aliases::U48, Address, Bytes, FixedBytes};
use chrono::{Duration, Utc};
Expand Down Expand Up @@ -153,6 +153,67 @@ pub trait Is4337Encodable {

Ok(user_op_hash)
}

/// V2 of [`sign_and_execute`] targeting the wallet provider's
/// path-versioned RPC at `/v2/rpc/{network}`.
///
/// Happy path only: requests protocol sponsorship by calling
/// `pm_sponsorUserOperation` with `SponsorshipContext::Protocol`,
/// merges the response's gas fields (and any paymaster fields the
/// response carries) into the `UserOp`, signs locally, and submits via
/// `eth_sendUserOperation` on the V2 path.
///
/// Decline handling — parsing the `-32602 "sponsorship declined"`
/// payload and retrying as self-sponsored with
/// `SponsorshipContext::SelfSponsoredToken` — is not implemented yet
/// and will be added in a follow-up. A decline today surfaces as the
/// RPC error from `pm_sponsor_user_operation`.
///
/// V1 [`sign_and_execute`] remains the active path for every existing
/// UniFFI export until its caller in `transactions/mod.rs` opts in to
/// V2 explicitly. See `bedrock/src/transactions/transaction.md` for
/// the on-device wire contract.
///
/// # Errors
/// * Returns `RpcError` if any RPC operation fails
/// * Returns `RpcError` if signing fails
/// * Returns `RpcError` if the global HTTP client has not been initialized
async fn sign_and_execute_v2(
&self,
safe_account: &SafeSmartAccount,
network: Network,
metadata: Option<Self::MetadataArg>,
) -> Result<FixedBytes<32>, RpcError> {
// 0. Global RPC client
let rpc_client = crate::transactions::rpc::get_rpc_client()?;

// 1. Preflight UserOperation
let mut user_operation =
self.build_preflight_user_operation(safe_account.wallet_address, metadata)?;

// 2. Request protocol sponsorship via V2 pm_sponsorUserOperation
let sponsor_response = rpc_client
.pm_sponsor_user_operation(
network,
&user_operation,
*ENTRYPOINT_4337,
&SponsorshipContext::Protocol,
)
.await?;

// 3. Merge gas (and paymaster fields if any) into the `UserOp`.
// For SponsorshipContext::Protocol the response carries only
// gas fields; paymaster fields stay None.
user_operation = user_operation.with_sponsorship_data(&sponsor_response);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

here we need to return, so the app can display a consent prompt when they need to self-sponsor

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.

Yeap, I'll handle this in the follow-up PR


// 4. Sign with fresh validity timestamps
safe_account.sign_user_operation(&mut user_operation, network)?;

// 5. Submit via the V2 RPC path
rpc_client
.send_user_operation_v2(network, &user_operation, *ENTRYPOINT_4337)
.await
}
}

#[cfg(test)]
Expand Down Expand Up @@ -304,6 +365,143 @@ mod tests {
assert_eq!(updated_user_op.max_fee_per_gas, U128::from(900));
}

/// Protocol-sponsored V2 response (empty context): gas fields overwritten,
/// paymaster fields stay at their preflight defaults because the response
/// omits them.
#[test]
fn test_with_sponsorship_data_protocol() {
use crate::transactions::rpc::PmSponsorUserOperationResponse;

let mut user_op = UserOperation::new_with_defaults(
address!("0x1111111111111111111111111111111111111111"),
U256::ZERO,
Bytes::from_str("0x1234").unwrap(),
);
// Seed paymaster fields with the preflight defaults so we can assert
// they survive the merge unchanged.
user_op.paymaster = None;
user_op.paymaster_data = None;
user_op.paymaster_verification_gas_limit = None;
user_op.paymaster_post_op_gas_limit = None;

let sponsor_response = PmSponsorUserOperationResponse {
call_gas_limit: U128::from(500),
verification_gas_limit: U128::from(400),
pre_verification_gas: U256::from(300),
max_fee_per_gas: U128::from(900),
max_priority_fee_per_gas: U128::from(800),
paymaster: None,
paymaster_verification_gas_limit: None,
paymaster_post_op_gas_limit: None,
paymaster_data: None,
};

let updated = user_op.with_sponsorship_data(&sponsor_response);

assert_eq!(updated.call_gas_limit, U128::from(500));
assert_eq!(updated.verification_gas_limit, U128::from(400));
assert_eq!(updated.pre_verification_gas, U256::from(300));
assert_eq!(updated.max_fee_per_gas, U128::from(900));
assert_eq!(updated.max_priority_fee_per_gas, U128::from(800));
assert!(updated.paymaster.is_none());
assert!(updated.paymaster_data.is_none());
assert!(updated.paymaster_verification_gas_limit.is_none());
assert!(updated.paymaster_post_op_gas_limit.is_none());
}

/// A protocol-sponsored response clears any stale paymaster fields that
/// were already on the `UserOp` — the response is the source of truth, so
/// `None` overwrites prior `Some(...)`.
#[test]
fn test_with_sponsorship_data_protocol_clears_stale_paymaster() {
use crate::transactions::rpc::PmSponsorUserOperationResponse;

let mut user_op = UserOperation::new_with_defaults(
address!("0x1111111111111111111111111111111111111111"),
U256::ZERO,
Bytes::from_str("0x1234").unwrap(),
);
// Pre-populate paymaster fields as if a prior self-sponsored merge had
// happened on this UserOp; the protocol-sponsored merge below must
// clear them.
user_op.paymaster =
Some(address!("0x3333333333333333333333333333333333333333"));
user_op.paymaster_data = Some(Bytes::from_str("0xdead").unwrap());
user_op.paymaster_verification_gas_limit = Some(U128::from(999));
user_op.paymaster_post_op_gas_limit = Some(U128::from(888));

let sponsor_response = PmSponsorUserOperationResponse {
call_gas_limit: U128::from(500),
verification_gas_limit: U128::from(400),
pre_verification_gas: U256::from(300),
max_fee_per_gas: U128::from(900),
max_priority_fee_per_gas: U128::from(800),
paymaster: None,
paymaster_verification_gas_limit: None,
paymaster_post_op_gas_limit: None,
paymaster_data: None,
};

let updated = user_op.with_sponsorship_data(&sponsor_response);

assert!(updated.paymaster.is_none(), "stale paymaster not cleared");
assert!(
updated.paymaster_data.is_none(),
"stale paymaster_data not cleared"
);
assert!(
updated.paymaster_verification_gas_limit.is_none(),
"stale paymaster_verification_gas_limit not cleared"
);
assert!(
updated.paymaster_post_op_gas_limit.is_none(),
"stale paymaster_post_op_gas_limit not cleared"
);
}

/// Self-sponsored V2 response (token context): every field, including the
/// paymaster ones, is merged onto the `UserOp`.
#[test]
fn test_with_sponsorship_data_self_sponsored() {
use crate::transactions::rpc::PmSponsorUserOperationResponse;

let user_op = UserOperation::new_with_defaults(
address!("0x1111111111111111111111111111111111111111"),
U256::ZERO,
Bytes::from_str("0x1234").unwrap(),
);

let sponsor_response = PmSponsorUserOperationResponse {
call_gas_limit: U128::from(500),
verification_gas_limit: U128::from(400),
pre_verification_gas: U256::from(300),
max_fee_per_gas: U128::from(900),
max_priority_fee_per_gas: U128::from(800),
paymaster: Some(address!("0x2222222222222222222222222222222222222222")),
paymaster_verification_gas_limit: Some(U128::from(600)),
paymaster_post_op_gas_limit: Some(U128::from(700)),
paymaster_data: Some(Bytes::from_str("0xabcd").unwrap()),
};

let updated = user_op.with_sponsorship_data(&sponsor_response);

assert_eq!(
updated.paymaster,
Some(address!("0x2222222222222222222222222222222222222222"))
);
assert_eq!(
updated.paymaster_data,
Some(Bytes::from_str("0xabcd").unwrap())
);
assert_eq!(
updated.paymaster_verification_gas_limit,
Some(U128::from(600))
);
assert_eq!(updated.paymaster_post_op_gas_limit, Some(U128::from(700)));
assert_eq!(updated.call_gas_limit, U128::from(500));
assert_eq!(updated.max_fee_per_gas, U128::from(900));
}

#[test]
fn test_get_paymaster_and_data_no_paymaster() {
let user_op = UserOperation {
Expand Down
1 change: 1 addition & 0 deletions bedrock/src/transactions/custom_bundler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ fn parse_json_rpc_response(response_bytes: &[u8]) -> Result<Value, RpcError> {
return Err(RpcError::RpcResponseError {
code: ep.code,
error_message: ep.message,
data: ep.data.and_then(|d| serde_json::to_string(&d).ok()),
});
}

Expand Down
Loading
Loading