Skip to content
Merged
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
375 changes: 375 additions & 0 deletions docs/architecture/transactions/prepare_sign_tx.md
Original file line number Diff line number Diff line change
@@ -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<br/>{ token, amount, recipient, ... }
Note over Server: Server encodes transfer(...)<br/>and wraps it in Safe<br/>execTransaction, then computes<br/>userOpHash. Full UserOp cached<br/>server-side.
Server-->>App: { operationHash }
App->>User: Confirm: sign operationHash<br/>(opaque to user, trust required)
User-->>App: Approve
App->>App: Sign operationHash with device key
App->>Server: POST /transfer/send<br/>{ operationHash, signature }
Note over Server: Retrieves cached UserOp,<br/>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 = <decoded intent>
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
Comment on lines +164 to +167
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Align no-paymaster response schema with V2 parser

This states that bundler-sponsored responses have zeroed gas and absent paymaster fields, but the SDK’s V2 parser (PmSponsorUserOperationResponse) currently requires paymasterVerificationGasLimit and paymasterPostOpGasLimit and tests expect them present (often as 0x0). If an endpoint is implemented from this doc, Bedrock will fail to deserialize the sponsorship response and transaction execution will stop with a JSON/RPC error.

Useful? React with 👍 / 👎.

`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"<br/>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.
Comment on lines +270 to +274
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Remove unsupported automatic decline-retry claim

The document says -32602 decline payloads trigger a self-sponsored retry, but current V2 execution does not implement that branch: RPC errors are surfaced as RpcResponseError and sign_and_execute_v2 returns immediately on pm_sponsorUserOperation failure. As written, this promises behavior Bedrock does not perform and can mislead integrators and auditors about failure handling.

Useful? React with 👍 / 👎.


### 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": <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 `/<version>/rpc/<network>`, where `<version>` 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/`)
Loading