diff --git a/bedrock/src/transactions/contracts/prepare_sign_tx.md b/bedrock/src/transactions/contracts/prepare_sign_tx.md new file mode 100644 index 00000000..5dff8f9d --- /dev/null +++ b/bedrock/src/transactions/contracts/prepare_sign_tx.md @@ -0,0 +1,317 @@ +# Prepare & Sign Transaction (V2 flow) + +This document describes the **V2** on-device flow: how Bedrock — the +open-source, on-device SDK that powers the wallet — turns a user intent +(e.g. "send 5 WLD to `0x…`") into a signed +[ERC-4337 UserOperation](https://eips.ethereum.org/EIPS/eip-4337) that lands on +chain. The earlier prepare/send flow is being phased out per transaction type; +see [Versioning and compatibility](#versioning-and-compatibility) below. + +It is a living document. The wallet's sponsorship policy evolves over time; +when it changes, this file changes with it. The on-device steps Bedrock performs +do not depend on the server's policy — only on the wire contract described +below. + +## Trust model + +The wallet is **self-custodial**. The user's signing key never leaves the +device, and the user should sign only payloads they can independently verify. +Bedrock is structured around that invariant: + +- **Bedrock constructs the calldata locally.** All encoding — ERC-20 + calls, Safe `executeUserOp` wrapping, and any composition needed for a + given transaction — happens on device, using + [Alloy](https://github.com/alloy-rs/core) primitives that the user (or a + third-party auditor) can inspect by reading the Bedrock source. +- **The user signs the UserOp hash, not a server-provided blob.** The hash is + derived from the fully-assembled UserOp (sender, nonce, callData, gas + fields, paymaster fields if any) per + [ERC-4337 §4.1](https://eips.ethereum.org/EIPS/eip-4337#useroperation). + +## What this design replaces + +Earlier wallet flows had a remote service construct the calldata and the +UserOp hash from the user's intent (token, amount, recipient); the device +then signed the resulting hash. This design moves calldata construction +onto the device so the user signs only payloads the device can +independently verify. + +## High-level flow + +For every transaction: + +1. **Build callData.** Encode the contract call (ERC-20 `transfer`, ERC-4626 + `deposit`, etc.) using Alloy. +2. **Wrap in `executeUserOp`.** The user's wallet is a + [Safe smart account](https://docs.safe.global/) with the ERC-4337 module + installed. Bedrock wraps the inner call in a + `executeUserOp(to, value, data, operation)` invocation on the module so + the UserOp executes through the smart account when the EntryPoint + dispatches it. +3. **Compute the UserOp hash locally.** Used for confirmation UI and to verify + later that the chain transaction matches what was signed. +4. **Ask for sponsorship.** Bedrock calls `pm_sponsorUserOperation` with an + empty context. The endpoint either sponsors directly (the wallet pays gas + on the user's behalf) or returns a structured decline with the information + needed to retry as a self-paid transaction. +5. **(Decline branch only.) Retry as self-sponsored.** Bedrock retries the + request in self-sponsored mode using the token returned in the decline + payload; the endpoint then returns the gas estimates and paymaster + fields needed to finalise the UserOp. +6. **Sign.** Bedrock merges the gas (and paymaster, if any) fields into the + UserOp and signs locally with the device key. +7. **Submit.** `eth_sendUserOperation` ships the signed UserOp through the + relay; the relay forwards it to a bundler which calls `handleOps` on the + [EntryPoint](https://eips.ethereum.org/EIPS/eip-4337#entrypoint). +8. **Poll for receipt.** Bedrock polls `eth_getUserOperationReceipt` until + the UserOp is mined. + +From iOS or Android, all of the above is a single FFI call; the two +round-trips inside it (sponsor + send) are not exposed to the platform layer. + +## Sponsored path (wallet pays gas) + +```mermaid +sequenceDiagram + actor User + participant Bedrock as Bedrock (on-device) + participant Endpoint as Sponsorship endpoint + participant Bundler + participant EP as EntryPoint contract + + User->>Bedrock: Intent (send X WLD to Alice) + Bedrock->>Bedrock: Build callData (Alloy) + Bedrock->>Bedrock: Wrap in Safe executeUserOp + Bedrock->>Bedrock: Compute userOpHash + + Bedrock->>Endpoint: pm_sponsorUserOperation(userOp, entryPoint, {}) + Endpoint-->>Bedrock: gas fields = 0x0, paymaster fields absent + + Bedrock->>User: Confirm: sign userOpHash = + User-->>Bedrock: Approve + Bedrock->>Bedrock: Sign userOp with device key + + Bedrock->>Endpoint: eth_sendUserOperation(signedUserOp, entryPoint) + Endpoint->>Bundler: forward + Bundler->>EP: handleOps([signedUserOp]) + EP-->>Bundler: tx hash + Bundler-->>Endpoint: tx hash + Endpoint-->>Bedrock: userOpHash + + Bedrock->>Endpoint: poll eth_getUserOperationReceipt + Endpoint-->>Bedrock: receipt + Bedrock-->>User: ✓ Sent +``` + +**Wire shape — sponsored response:** + +```json +{ + "callGasLimit": "0x0", + "verificationGasLimit": "0x0", + "preVerificationGas": "0x0", + "maxFeePerGas": "0x0", + "maxPriorityFeePerGas": "0x0" +} +``` + +The five gas fields are zeroed and the paymaster fields (`paymaster`, +`paymasterData`, `paymasterVerificationGasLimit`, `paymasterPostOpGasLimit`) +are **absent** from the response, not present-with-zero values. Bedrock +detects absent fields via `Option<…>` in Rust and skips +`with_paymaster_data()`. The bundler estimates gas when the UserOp is +submitted, so explicit gas values are not required from the wallet on this +path. + +## Decline → self-sponsored retry (user pays gas in an ERC-20 token) + +When the endpoint declines to sponsor, the wallet falls back to the user +paying gas in an ERC-20 token (e.g. WLD) routed through an ERC-20 paymaster +contract. The flow is mechanical: + +```mermaid +sequenceDiagram + actor User + participant Bedrock as Bedrock (on-device) + participant Endpoint as Sponsorship endpoint + participant Paymaster as ERC-20 paymaster contract + participant Bundler + + User->>Bedrock: Intent + Bedrock->>Bedrock: Build callData, wrap in Safe executeUserOp + + Bedrock->>Endpoint: pm_sponsorUserOperation(userOp, entryPoint, {}) + Endpoint-->>Bedrock: -32602 "sponsorship declined"
data: { token, paymasterAddress, costNative?, costToken? } + + Bedrock->>Bedrock: Prepare self-sponsored userOp + + Bedrock->>Endpoint: pm_sponsorUserOperation(updatedUserOp, entryPoint, { token }) + Endpoint-->>Bedrock: real gas + paymaster + paymasterData + + Bedrock->>User: Confirm: pay ~Y WLD in gas (decoded from cost fields) + User-->>Bedrock: Approve + Bedrock->>Bedrock: Merge gas + paymaster fields, sign userOp + + Bedrock->>Endpoint: eth_sendUserOperation(signedUserOp, entryPoint) + Endpoint->>Bundler: forward + Bundler-->>Endpoint: tx hash + Endpoint-->>Bedrock: userOpHash +``` + +**Wire shape — decline payload (`-32602`):** + +| Field | Required | Meaning | +| ------------------ | -------- | ------------------------------------------------------------------------------------------------ | +| `token` | yes | ERC-20 token address the user should pay gas in (e.g. WLD). Bedrock uses this for the retry. | +| `paymasterAddress` | yes | ERC-20 paymaster contract that will pull the fee at execution time. | +| `costNative` | no | Advisory: estimated gas cost in native units (hex wei). May be absent. | +| `costToken` | no | Advisory: estimated cost in the token's smallest unit (hex). Surface to the user when available. | + +The two required fields are sufficient to build the retry; the advisory cost +fields are best-effort and Bedrock must tolerate their absence. The wallet +should never proceed with the retry if `token` or `paymasterAddress` is +missing — it should surface an error to the user instead. + +**Wire shape — self-sponsored response:** + +```json +{ + "callGasLimit": "0x…", + "verificationGasLimit": "0x…", + "preVerificationGas": "0x…", + "maxFeePerGas": "0x…", + "maxPriorityFeePerGas": "0x…", + "paymaster": "0x…", + "paymasterData": "0x…", + "paymasterVerificationGasLimit": "0x…", + "paymasterPostOpGasLimit": "0x…" +} +``` + +All gas fields populated, all paymaster fields present. Bedrock merges them +into the UserOp and calls `with_paymaster_data()` before signing. + +## Per-step details + +### 1. Build callData + +Done locally with Alloy. For an ERC-20 transfer this is +`transfer(to, amount)`. For ERC-4626 deposits, Permit2 transfers, vault +migrations, etc., the relevant function is encoded against the on-chain ABI. + +Nothing leaves the device at this step. + +### 2. Wrap in `executeUserOp` + +The actual call is wrapped in `executeUserOp(to, value, data, operation)` on +the Safe's ERC-4337 module. This becomes the `callData` field of the UserOp; +when the EntryPoint dispatches the UserOp, it calls `executeUserOp` on the +smart account, which performs the inner call. + +### 3. Compute the UserOp hash + +ERC-4337's UserOp hash is deterministic given the fully-assembled UserOp, +the EntryPoint address, and the chain ID. Bedrock computes it locally so the +user can be shown exactly what they are about to sign. + +### 4. First sponsorship call + +`pm_sponsorUserOperation` is called with the partial UserOp (sender, nonce, +callData, signature placeholder, optional factory/factoryData) and an empty +context. The endpoint inspects current conditions and either: + +- returns a sponsored response (zeroed gas, absent paymaster fields), or +- returns `-32602 "sponsorship declined"` with the structured payload above. + +### 5. Self-sponsored retry + +When the endpoint declines to sponsor, Bedrock retries the request in +self-sponsored mode using the token returned in the decline payload. The +second response carries the gas estimates and paymaster fields needed to +finalise the UserOp. + +### 6. Sign + +The UserOp is finalised by merging the gas (and, on the retry path, the +paymaster) fields. Bedrock recomputes the UserOp hash to ensure it still +corresponds to the intent shown to the user, then signs with the device key. + +### 7. Submit + +`eth_sendUserOperation(signedUserOp, entryPoint)`. The endpoint forwards to +a bundler. Bedrock receives the userOpHash back and stores it for receipt +polling. + +### 8. Poll for receipt + +`eth_getUserOperationReceipt` is polled until the UserOp is mined or until a +deadline is reached. The user-facing state machine (`pending`, `mined`, +`failed`) is derived from the receipt. + +## Error handling + +All responses use HTTP `200 OK`; success vs. error is indicated by the +JSON-RPC body. Bedrock categorises outcomes as follows: + +| Category | How it manifests | Bedrock's response | +| ----------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | +| Network / transport | HTTP error, timeout | Surface to user as transient; the user may retry. No signing occurred. | +| Sponsorship declined | `-32602` with `token` + `paymasterAddress` | Run the self-sponsored retry (steps 5–7). | +| Invalid request | `-32602` without the decline payload | Bug in Bedrock — should not happen in production. Surface generically; do not retry. | +| Endpoint internal error | `-32603` | Surface as transient; user may retry. No signing occurred. | +| Bundler error on send | error on `eth_sendUserOperation` | Surface to user; the UserOp was signed but not accepted by the bundler. Bedrock does not auto-retry sends to avoid duplicates. | +| Mined-revert | receipt with `success: false` | Surface as a failed transaction. The on-chain effect is whatever the EntryPoint did before reverting (typically nothing). | + +In every category Bedrock retains the locally-built calldata and the +locally-computed userOpHash, so the user-facing failure message can be +specific without trusting the endpoint to describe what went wrong. + +## Versioning and compatibility + +The sponsorship endpoint is **path-versioned**. JSON-RPC method calls are +issued to `//rpc/`, where `` is selected by +Bedrock per method. This document describes the **v2** contract — the +current target for new transaction types. + +A small number of legacy transaction types still route through an older +version, which serves a different prepare-then-send shape than this document +describes. Those are being migrated to v2 one transaction type at a time and +are not covered here. + +**Compatibility within a version:** + +- **Adding** a method or a response field is non-breaking. Bedrock tolerates + unknown response fields and treats missing advisory fields as absent. For + example, `costNative` and `costToken` in the decline payload may be added, + removed, or replaced with similar advisory data without breaking the wire + contract. +- **Changing the meaning** of a required field (`token`, `paymasterAddress`, + any gas field), removing a required field, or altering a method's + semantics is a **breaking change** and requires a new path version, not an + in-place change. + +**Why this matters for consumers of Bedrock:** + +The version path is internal to Bedrock's network layer. iOS, Android, and +third-party integrators interact only with the Bedrock FFI surface, which is +versioned independently. A path-version change on the endpoint does not +ripple out to platform code: a new Bedrock release ships with new endpoint +routing, and older Bedrock releases continue to function against the +matching older endpoint version. This decoupling is the safety net — any +unexpected change on the server side can be addressed by shipping a Bedrock +update without breaking already-installed devices. + +The wallet does **not** automatically downgrade between versions in response +to errors. A v2 failure surfaces to the user as a transaction failure; +Bedrock will not silently re-send the same intent through a different +version with different trust properties. + +## References + +- [ERC-4337 — Account Abstraction Using EntryPoint](https://eips.ethereum.org/EIPS/eip-4337) +- [EIP-7677 — Paymaster Web Service Capability](https://eips.ethereum.org/EIPS/eip-7677) +- [EIP-7769 — JSON-RPC error codes for ERC-4337](https://eips.ethereum.org/EIPS/eip-7769) +- [JSON-RPC 2.0 Specification](https://www.jsonrpc.org/specification) +- [Safe Smart Account documentation](https://docs.safe.global/) +- Bedrock source: `bedrock/src/transactions/` (`rpc.rs`, `mod.rs`, + `contracts/`) diff --git a/bedrock/src/transactions/rpc.rs b/bedrock/src/transactions/rpc.rs index bd137f6a..f339310e 100644 --- a/bedrock/src/transactions/rpc.rs +++ b/bedrock/src/transactions/rpc.rs @@ -39,15 +39,21 @@ pub enum Id { /// Supported RPC methods in Bedrock #[derive(Debug, Clone, Serialize)] pub enum RpcMethod { - /// Request sponsorship for a `UserOperation` + /// Request sponsorship for a `UserOperation` (V1) #[serde(rename = "wa_sponsorUserOperation")] SponsorUserOperation, + /// Request sponsorship for a `UserOperation` (V2) + #[serde(rename = "pm_sponsorUserOperation")] + PmSponsorUserOperation, /// Queries the status of a `UserOperation` #[serde(rename = "wa_getUserOperationReceipt")] WaGetUserOperationReceipt, - /// Submit a signed `UserOperation` + /// Submit a signed `UserOperation` (V1) #[serde(rename = "eth_sendUserOperation")] SendUserOperation, + /// Submit a signed `UserOperation` (V2) + #[serde(rename = "eth_sendUserOperation")] + SendUserOperationV2, /// Make a read call to a smart contract #[serde(rename = "eth_call")] EthCall, @@ -86,8 +92,11 @@ impl RpcMethod { pub const fn as_str(&self) -> &'static str { match self { Self::SponsorUserOperation => "wa_sponsorUserOperation", + Self::PmSponsorUserOperation => "pm_sponsorUserOperation", Self::WaGetUserOperationReceipt => "wa_getUserOperationReceipt", - Self::SendUserOperation => "eth_sendUserOperation", + Self::SendUserOperation | Self::SendUserOperationV2 => { + "eth_sendUserOperation" + } Self::EthCall => "eth_call", Self::SupportedEntryPoints => "eth_supportedEntryPoints", } @@ -216,6 +225,64 @@ pub struct SponsorUserOperationResponse { pub provider_name: RpcProviderName, } +/// Response from `pm_sponsorUserOperation` (V2) +/// +/// Paymaster fields (`paymaster`, `paymaster_data`, +/// `paymaster_verification_gas_limit`, `paymaster_post_op_gas_limit`) are +/// absent from the bundler-sponsored response shape and present on the +/// self-sponsored (token) response — see +/// `bedrock/src/transactions/contracts/prepare_sign_tx.md`. They are modelled as +/// `Option` so both shapes deserialize. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PmSponsorUserOperationResponse { + /// Call gas limit + pub call_gas_limit: U128, + /// Verification gas limit + pub verification_gas_limit: U128, + /// Pre-verification gas + pub pre_verification_gas: U256, + /// Max fee per gas + pub max_fee_per_gas: U128, + /// Max priority fee per gas + pub max_priority_fee_per_gas: U128, + /// Paymaster address (absent on the bundler-sponsored path) + pub paymaster: Option
, + /// Paymaster verification gas limit (absent on the bundler-sponsored path) + pub paymaster_verification_gas_limit: Option, + /// Paymaster post-op gas limit (absent on the bundler-sponsored path) + pub paymaster_post_op_gas_limit: Option, + /// Paymaster data (absent on the bundler-sponsored path) + pub paymaster_data: Option, +} + +/// Context object passed as the third parameter of `pm_sponsorUserOperation`. +/// +/// V2 sponsorship is a two-mode protocol: an initial protocol-sponsored +/// attempt with empty context, followed (on a structured decline) by a +/// self-sponsored retry that names the ERC-20 token paying for gas. See +/// `bedrock/src/transactions/contracts/prepare_sign_tx.md`. +#[derive(Debug, Clone)] +pub enum SponsorshipContext { + /// Empty context — request protocol sponsorship (the wallet provider + /// pays gas). Distinct from the ERC-4337 notion of bundler sponsorship, + /// which implies a user-appointed bundler choosing to sponsor. + Protocol, + /// Self-sponsored mode with the given ERC-20 token paying for gas. + SelfSponsoredToken(Address), +} + +impl SponsorshipContext { + fn to_json_value(&self) -> serde_json::Value { + match self { + Self::Protocol => serde_json::json!({}), + Self::SelfSponsoredToken(token) => { + serde_json::json!({ "token": format!("{token:?}") }) + } + } + } +} + /// Response from `wa_getUserOperationReceipt` #[derive(Debug, Deserialize, uniffi::Record, Clone)] #[serde(rename_all = "camelCase")] @@ -242,7 +309,7 @@ pub struct WaGetUserOperationReceiptResponse { /// RPC client for handling 4337 `UserOperation` requests /// -/// This client communicates with the app-backend's RPC endpoint at `/v1/rpc/{network}`. +/// This client communicates with the RPC endpoint at `/v1/rpc/{network}` and `/v2/rpc/{network}`. pub struct RpcClient { http_client: Arc, } @@ -259,7 +326,9 @@ impl RpcClient { /// Constructs the RPC endpoint URL for the specified network and method fn rpc_endpoint(network: Network, method: &RpcMethod) -> String { let version = match method { - RpcMethod::EthCall => "v2", + RpcMethod::EthCall + | RpcMethod::PmSponsorUserOperation + | RpcMethod::SendUserOperationV2 => "v2", _ => "v1", }; format!("/{version}/rpc/{}", network.network_name()) @@ -367,6 +436,42 @@ impl RpcClient { .await } + /// Requests sponsorship for a `UserOperation` via `pm_sponsorUserOperation` (V2) + /// + /// Sends the three-element params vec `[userOperation, entryPoint, context]` + /// per the V2 contract. `context` is `SponsorshipContext::Protocol` + /// (serializes to `{}`) for the initial attempt and + /// `SponsorshipContext::SelfSponsoredToken` for the self-sponsored retry + /// after a decline. + /// + /// # Errors + /// + /// Returns an error if: + /// - The HTTP request fails + /// - The request serialization fails + /// - The response parsing fails + /// - The RPC returns an error response + pub async fn pm_sponsor_user_operation( + &self, + network: Network, + user_operation: &UserOperation, + entry_point: Address, + context: &SponsorshipContext, + ) -> Result { + let params = vec![ + serde_json::to_value(user_operation).map_err(|_| RpcError::JsonError)?, + serde_json::Value::String(format!("{entry_point:?}")), + context.to_json_value(), + ]; + self.rpc_call( + network, + RpcMethod::PmSponsorUserOperation, + params, + RpcProviderName::Any, + ) + .await + } + /// Submits a signed `UserOperation` via `eth_sendUserOperation` /// /// # Errors @@ -398,6 +503,45 @@ impl RpcClient { }) } + /// Submits a signed `UserOperation` via the V2 RPC endpoint (`/v2/rpc/{network}`). + /// + /// Identical wire format to [`send_user_operation`] but routed to the V2 path. + /// Use this from V2 execution flows (e.g. `sign_and_execute_v2`) so that + /// V1 callers remain fully on the legacy path. + /// + /// # Errors + /// + /// Returns an error if: + /// - The HTTP request fails + /// - The request serialization fails + /// - The response parsing fails + /// - The RPC returns an error response + /// - The returned user operation hash is invalid + pub async fn send_user_operation_v2( + &self, + network: Network, + user_operation: &UserOperation, + entrypoint: Address, + ) -> Result, RpcError> { + let params = vec![ + serde_json::to_value(user_operation).map_err(|_| RpcError::JsonError)?, + serde_json::Value::String(format!("{entrypoint:?}")), + ]; + + let result: String = self + .rpc_call( + network, + RpcMethod::SendUserOperationV2, + params, + RpcProviderName::Any, + ) + .await?; + + FixedBytes::from_hex(&result).map_err(|e| RpcError::InvalidResponse { + error_message: format!("Invalid userOpHash format: {e}"), + }) + } + /// Gets a custom user operation receipt for a given userOp hash /// /// # Errors @@ -706,4 +850,115 @@ mod tests { assert_eq!(serialized["paymasterVerificationGasLimit"], "0xa"); assert_eq!(serialized["paymasterPostOpGasLimit"], "0x0"); } + + #[test] + fn test_rpc_endpoint_v2_methods_route_to_v2() { + let network = Network::WorldChain; + for method in [ + RpcMethod::PmSponsorUserOperation, + RpcMethod::SendUserOperationV2, + RpcMethod::EthCall, + ] { + let url = RpcClient::rpc_endpoint(network, &method); + assert!( + url.starts_with("/v2/"), + "{method:?} should route to /v2/, got {url}" + ); + } + } + + #[test] + fn test_rpc_endpoint_legacy_methods_stay_on_v1() { + let network = Network::WorldChain; + for method in [ + RpcMethod::SponsorUserOperation, + RpcMethod::SendUserOperation, + RpcMethod::WaGetUserOperationReceipt, + RpcMethod::SupportedEntryPoints, + ] { + let url = RpcClient::rpc_endpoint(network, &method); + assert!( + url.starts_with("/v1/"), + "{method:?} should route to /v1/, got {url}" + ); + } + } + + #[test] + fn test_rpc_endpoint_includes_network_name() { + let url = RpcClient::rpc_endpoint( + Network::WorldChain, + &RpcMethod::SendUserOperationV2, + ); + assert_eq!(url, "/v2/rpc/worldchain"); + + let url = + RpcClient::rpc_endpoint(Network::WorldChain, &RpcMethod::SendUserOperation); + assert_eq!(url, "/v1/rpc/worldchain"); + } + + #[test] + fn test_pm_sponsor_response_parsing() { + // Bundler-sponsored shape — paymaster fields are absent from the + // response body entirely (not present-with-zero). Modelled as + // Option, so all four deserialize to None. + let no_paymaster = json!({ + "callGasLimit": "0x0", + "verificationGasLimit": "0x0", + "preVerificationGas": "0x0", + "maxFeePerGas": "0x0", + "maxPriorityFeePerGas": "0x0", + }); + let r: PmSponsorUserOperationResponse = + serde_json::from_value(no_paymaster).unwrap(); + assert_eq!(r.call_gas_limit, U128::ZERO); + assert_eq!(r.verification_gas_limit, U128::ZERO); + assert_eq!(r.pre_verification_gas, U256::ZERO); + assert_eq!(r.max_fee_per_gas, U128::ZERO); + assert_eq!(r.max_priority_fee_per_gas, U128::ZERO); + assert!(r.paymaster.is_none()); + assert!(r.paymaster_verification_gas_limit.is_none()); + assert!(r.paymaster_post_op_gas_limit.is_none()); + assert!(r.paymaster_data.is_none()); + + // Self-sponsored shape — all four paymaster fields present with real + // values from Pimlico's ERC-20 paymaster. + let with_paymaster = json!({ + "callGasLimit": "0x212df", + "verificationGasLimit": "0x501ab", + "preVerificationGas": "0x350f7", + "maxFeePerGas": "0x7A5CF70D5", + "maxPriorityFeePerGas": "0x3B9ACA00", + "paymaster": "0x0000000000000039cd5e8aE05257CE51C473ddd1", + "paymasterVerificationGasLimit": "0x6dae", + "paymasterPostOpGasLimit": "0x706e", + "paymasterData": "0x01000066d1a1a4", + }); + let r: PmSponsorUserOperationResponse = + serde_json::from_value(with_paymaster).unwrap(); + assert_eq!( + r.paymaster, + Some(address!("0000000000000039cd5e8aE05257CE51C473ddd1")) + ); + assert_eq!( + r.paymaster_verification_gas_limit, + Some(U128::from(0x6dae_u32)) + ); + assert_eq!(r.paymaster_post_op_gas_limit, Some(U128::from(0x706e_u32))); + assert!(r.paymaster_data.is_some()); + } + + #[test] + fn test_sponsorship_context_serialization() { + // Protocol-sponsored: empty object is sent as the third param. + assert_eq!(SponsorshipContext::Protocol.to_json_value(), json!({})); + + // Self-sponsored: { "token": "0x..." } with a lowercase 0x-prefixed + // hex address, matching the entry_point formatting convention. + let token = address!("2cfc85d8e48f8eab294be644d9e25c3030863003"); + assert_eq!( + SponsorshipContext::SelfSponsoredToken(token).to_json_value(), + json!({ "token": "0x2cfc85d8e48f8eab294be644d9e25c3030863003" }) + ); + } }