From dcde82e413f102bfd4248af206252b6ed35fd770 Mon Sep 17 00:00:00 2001 From: Elden Park Date: Tue, 26 May 2026 17:18:32 -0700 Subject: [PATCH 1/2] docs: describe the on-device prepare-and-sign flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add docs/architecture/transactions/prepare_sign_tx.md — a public-facing description of how Bedrock turns a user intent into a signed ERC-4337 UserOperation, intended for external auditors of the wallet's self-custody guarantees. Sections: - Trust model: calldata is constructed locally; the user signs only payloads the device can independently verify. - What this design replaces: a sequenceDiagram of the legacy prepare/send flow where the server encoded calldata and the device signed an opaque hash — the two trust gaps that motivated the redesign. - High-level flow: build callData → wrap in Safe execTransaction → compute userOpHash → sponsor → sign → submit → poll. - Sponsored path and decline → self-sponsored retry, each with a sequenceDiagram and the JSON-RPC wire shapes. - Sponsorship decline payload contract: required token / paymasterAddress fields, optional advisory cost fields the wallet tolerates as absent. - Per-step details and observable error categories. - Versioning and compatibility: path-versioned endpoint, the non-breaking / breaking change distinction, and how Bedrock's FFI versioning decouples consumers from server version changes. The file is intended to be kept up to date as the on-device flow evolves; it documents the wire contract, not internal infrastructure. --- .../transactions/prepare_sign_tx.md | 375 ++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 docs/architecture/transactions/prepare_sign_tx.md diff --git a/docs/architecture/transactions/prepare_sign_tx.md b/docs/architecture/transactions/prepare_sign_tx.md new file mode 100644 index 00000000..81f13495 --- /dev/null +++ b/docs/architecture/transactions/prepare_sign_tx.md @@ -0,0 +1,375 @@ +# Prepare & Sign Transaction + +This document describes 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. + +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.** ERC-20 transfer encoding, + Safe `execTransaction` wrapping, paymaster `approve()` insertion (when + applicable) — all of it 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 remote sponsorship endpoint is a sponsor and a relay.** It chooses + whether to pay gas, attaches paymaster signatures when the user pays in an + ERC-20 token, and forwards the signed UserOp to a bundler. It does not + construct calldata, does not modify the user's calldata, and the on-chain + outcome can be diffed against what Bedrock built before signing. +- **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). + +The boundary is sharp: anything the server returns is treated as untrusted +input. Gas fields and paymaster fields are merged into the UserOp, but the +calldata Bedrock submits is byte-equal to the calldata Bedrock built locally, +modulo an `approve()` prepended by Bedrock itself on the ERC-20 retry path. + +## What this design replaces + +Prior to this design, the wallet sent transactions through a two-step +prepare/send flow served by a separate backend. The device transmitted the +user's _intent_ — token, amount, recipient — and the backend constructed +the calldata, wrapped it in a Safe `execTransaction`, computed the UserOp +hash, and returned only that hash to the device. The user's device signed +the hash without ever holding the bytes it represented. + +```mermaid +sequenceDiagram + actor User + participant App as Mobile app + participant Server as Prepare/send server + participant Bundler + + User->>App: Intent (send X WLD to Alice) + App->>Server: POST /transfer/prepare
{ token, amount, recipient, ... } + Note over Server: Server encodes transfer(...)
and wraps it in Safe
execTransaction, then computes
userOpHash. Full UserOp cached
server-side. + Server-->>App: { operationHash } + App->>User: Confirm: sign operationHash
(opaque to user; trust required) + User-->>App: Approve + App->>App: Sign operationHash with device key + App->>Server: POST /transfer/send
{ operationHash, signature } + Note over Server: Retrieves cached UserOp,
attaches signature, submits. + Server->>Bundler: signed UserOp + Bundler-->>Server: tx hash + Server-->>App: tx hash +``` + +Two properties of this legacy flow motivated the redesign: + +- **The user signs a hash, not its preimage.** `operationHash` is computed + by the server. The device has no independent way to confirm the hash + corresponds to the intent the user approved. A compromised or + misconfigured backend could in principle return a hash for a different + recipient, a different amount, or a different contract call, and the + device would sign it. +- **The calldata is held by the backend between prepare and send.** The + UserOp is cached server-side and only retrieved at send time. The device + cannot inspect what is about to be broadcast. + +The design described in the rest of this document closes both gaps by +moving calldata construction onto the device, computing the UserOp hash +locally from bytes the device holds, and treating the remote endpoint as a +sponsor-and-relay rather than a builder. + +## 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 `execTransaction`.** The user's wallet is a + [Safe smart account](https://docs.safe.global/) (ERC-4337 compatible + variant). Bedrock wraps the inner call in a Safe `execTransaction` so the + UserOp executes through the smart account. +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.) Prepare a self-sponsored retry.** Bedrock prepends + `approve(paymaster, amount)` to callData so the paymaster contract can pull + the ERC-20 fee at execution time, and re-issues `pm_sponsorUserOperation` + with the chosen token in context. The response carries real gas estimates + and a paymaster signature. +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_getUserOperationByHash` / + `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 execTransaction + 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 — bundler-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 execTransaction + + Bedrock->>Endpoint: pm_sponsorUserOperation(userOp, entryPoint, {}) + Endpoint-->>Bedrock: -32602 "sponsorship declined"
data: { token, paymasterAddress, costNative?, costToken? } + + Bedrock->>Bedrock: Prepend approve(paymaster, amount) to callData + Bedrock->>Bedrock: Rebuild Safe execTransaction with new callData + + 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. Bedrock uses this as the `approve()` spender. | +| `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 `execTransaction` + +The Safe smart account requires the actual call to be wrapped in a Safe +`execTransaction(target, value, data, operation, …)`. This becomes the +`callData` field of the UserOp. + +### 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 bundler-sponsored response (zeroed gas, absent paymaster fields), or +- returns `-32602 "sponsorship declined"` with the structured payload above. + +Bedrock never retries on transport errors or `-32603` (internal server +error) — those surface as errors to the user. The decline payload is the +only branch that triggers the self-sponsored retry. + +### 5. Self-sponsored retry + +When declined, Bedrock: + +1. Prepends an ERC-20 `approve(paymasterAddress, amount)` to callData so the + paymaster contract can transfer the fee at execution time. `amount` is + chosen high enough to cover the worst-case gas cost (the contract will + only pull what it actually charges). +2. Re-wraps the new callData in a Safe `execTransaction`. +3. Re-issues `pm_sponsorUserOperation` with `context = { "token": }`. + +The second response carries the real gas estimates and the paymaster +signature. + +### 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_getUserOperationByHash` (transaction-mined check) and +`eth_getUserOperationReceipt` (full receipt with logs) are polled until +mined or until a deadline is reached. The user-facing state machine +(`pending`, `mined`, `failed`) is derived from these results. + +## 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/`) From 4dca551f62c5a3d0202eed6a198054df2a2d89c2 Mon Sep 17 00:00:00 2001 From: Elden Park Date: Tue, 26 May 2026 17:27:46 -0700 Subject: [PATCH 2/2] Replace ';' w/ ',' as Mermaid treats it as separator --- .../transactions/prepare_sign_tx.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/architecture/transactions/prepare_sign_tx.md b/docs/architecture/transactions/prepare_sign_tx.md index 81f13495..f02e9973 100644 --- a/docs/architecture/transactions/prepare_sign_tx.md +++ b/docs/architecture/transactions/prepare_sign_tx.md @@ -56,7 +56,7 @@ sequenceDiagram App->>Server: POST /transfer/prepare
{ token, amount, recipient, ... } Note over Server: Server encodes transfer(...)
and wraps it in Safe
execTransaction, then computes
userOpHash. Full UserOp cached
server-side. Server-->>App: { operationHash } - App->>User: Confirm: sign operationHash
(opaque to user; trust required) + App->>User: Confirm: sign operationHash
(opaque to user, trust required) User-->>App: Approve App->>App: Sign operationHash with device key App->>Server: POST /transfer/send
{ operationHash, signature } @@ -311,14 +311,14 @@ mined or until a deadline is reached. The user-facing state machine 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). | +| 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