Relayer
This specification describes the ERC-4337 infrastructure that handles gas sponsorship for encrypted balance operations (publicToEncryptedTransfer, encryptedToPublicTransfer, transfer). It covers three components: a SharedAccount contract, a Paymaster contract, and an off-chain Paymaster Backend.
Table of Contents
- System Architecture
- SharedAccount Contract
- EBPaymaster Contract
- Paymaster Backend
- EBEMT Contract Gasless Functions
- UserOperation Construction
- Gas Estimation
- Partner Authentication Flow
- Bundler Configuration
- SDK Integration
- Deployment & Operations
- Security Model
1. System Architecture
Off-chain
┌──────────┐ 1. request ┌─────────────────┐
│ User SDK │──────────────►│ Partner Backend │
│ │ │ (per partner) │
│ │ └────────┬────────┘
│ │ 2. forward UserOp
│ │ + partner creds
│ │ │
│ │ ▼
│ │ ┌─────────────────┐
│ │ │Paymaster Backend│
│ │ │ (self-hosted) │
│ │ └────────┬────────┘
│ │ • validates partner
│ │ • checks budget
│ │ • signs paymasterData
│ │ │
│ │ ┌────────┘
│ │ 4. signed │ 3. paymasterData
│ │ paymasterData│
│ │◄──────────────┘
│ │ (via Partner Backend)
│ │
│ │ 5. submit UserOp
└────┬─────┘ (with paymasterData)
│
▼ On-chain
┌──────────────┐ ┌──────────────────────────────────────────┐
│ Bundler │────────►│ EntryPoint v0.9 │
│ (Coinbase/ │ │ 0x433709...eD8D009 │
│ Pimlico/ │ │ │
│ Alchemy) │ │ 1. SharedAccount.validateUserOp() │
└──────────────┘ │ → nonce key = keccak256(callData) │
│ 2. EBPaymaster.validatePaymasterUserOp()│
│ → verifies SK_paymaster signature │
│ 3. SharedAccount.execute(target, data) │
│ → calls EBEMT function │
└──────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ EBEMT │
│ publicToEncryptedTransferWithAuth / │
│ encryptedToPublicTransfer / │
│ encryptedTransfer │
└──────────────────────────────────────────┘
Component Responsibilities
| Component | Responsibility | State |
|---|---|---|
| SharedAccount | Generic ownerless ERC-4337 account; forwards calls to EBEMT | On-chain, stateless (nonces managed by EntryPoint) |
| EBPaymaster | Verifies backend signature, pays gas via EntryPoint deposit | On-chain, minimal (signer address + deposit) |
| Paymaster Backend | Partner auth, budget enforcement, operation allowlisting, signs paymasterData | Off-chain, stateful (partner registry, usage DB) |
| Bundler | Collects UserOps, submits to EntryPoint | Third-party (Pimlico/Alchemy/Self-hosted) |
2. SharedAccount Contract
An ownerless ERC-4337 smart account that anyone can use to submit transactions.
Design
- No owner, no signature check. The
validateUserOpfunction does not verify a cryptographic signature. Instead, it validates that the nonce key encodes the callData hash — preventing nonce collisions between concurrent users. - Paymaster always required.
validateUserOpreverts ifpaymasterAndDatais empty — even if the SharedAccount holds ETH or has an EntryPoint deposit. This is an explicit on-chain invariant, not just an economic assumption. - Unrestricted execution. The SharedAccount can call any target contract with any selector. The Paymaster Backend decides what to sponsor by inspecting
callDataoff-chain before signing. - Shared across L2 and L3. One deployment serves all operations.
Execution Interfaces
The SharedAccount implements two standard execution interfaces:
- IAccountExecute (ERC-4337) —
executeUserOp(userOp, userOpHash)for single-call execution. The UserOp'scallDatastarts with theexecuteUserOpselector followed byabi.encode(target, value, data). The EntryPoint calls this during the execution phase. - ERC-7821 (Minimal Batch Executor) —
execute(mode, executionData)for batch execution. Multiple calls (e.g., publicToEncryptedTransfer + transfer) in a single UserOp. Uses the OZERC7821base contract with_erc7821AuthorizedExecutorrestricted to the EntryPoint only.
Nonce Key Scheme
ERC-4337 nonces are composed of a 192-bit key and a 64-bit sequence. Different keys form independent nonce lanes. The SharedAccount derives the key from the callData:
nonceKey = uint192(uint256(keccak256(userOp.callData)))
This ensures:
- No collisions — two UserOps with different callData (different proofs, different operations) use different nonce lanes.
- No replay — the EntryPoint enforces nonce uniqueness within each lane.
- Ordering within a lane — if the same user submits two identical operations (unlikely), they serialize correctly.
Implementation
See contracts/src/erc-4337/SharedAccount.sol for the full implementation. Key points:
- Inherits
IAccount,IAccountExecute, and OZ'sERC7821 validateUserOpchecksnonceKey == uint192(keccak256(callData))executeUserOpdecodesabi.encode(target, value, data)fromuserOp.callDataexecute(mode, executionData)via ERC-7821 for batch operations_erc7821AuthorizedExecutorrestricted to EntryPoint only- Public
nonceKeyFor(callData)helper for SDK nonce derivation
Deployment
Deploy once via CREATE2 with a fixed salt for a deterministic address. The address is a constant in the SDK.
3. EBPaymaster Contract
A minimal on-chain paymaster. It trusts a single ECDSA signer (the Paymaster Backend) and verifies its signature over the UserOperation hash. All partner logic, budgets, and rate limits live off-chain.
paymasterAndData Layout (v0.9)
The paymaster uses the EntryPoint v0.9 standard paymaster signature format. OZ's ERC4337Utils helpers parse this automatically:
Offset Size Field
────── ──── ─────
[0:20] 20 address paymaster
[20:36] 16 uint128 paymasterVerificationGasLimit
[36:52] 16 uint128 paymasterPostOpGasLimit
[52:58] 6 uint48 validUntil ← paymasterData (userOp.paymasterData())
[58:123] 65 bytes ECDSA signature (r:32 + s:32 + v:1) ← paymasterSignature (userOp.paymasterSignature())
[123:125] 2 uint16 signatureSize (= 65)
[125:133] 8 bytes8 PAYMASTER_SIG_MAGIC (0x22e325a297439656)
Total: 133 bytes
OZ's ERC4337Utils helpers (userOp.paymasterData() and userOp.paymasterSignature()) parse this layout automatically by detecting the magic suffix.
The backend signs: toEthSignedMessageHash(keccak256(abi.encode(userOpHash, validUntil))) — the EIP-191 prefix (\x19Ethereum Signed Message:\n32) is applied before signing, and the on-chain contract uses ECDSA.recover with toEthSignedMessageHash to match.
Implementation
See contracts/src/erc-4337/EBPaymaster.sol for the full implementation. Key points:
- Uses
userOp.paymasterData()anduserOp.paymasterSignature()from OZ'sERC4337Utils - Uses
ERC4337Utils.packValidationData(sigValid, 0, validUntil)to pack the return value postOpis a no-op — gas accounting is off-chain- Admin:
setSigner,deposit,withdrawDeposit,addStake,unlockStake,withdrawStake
Key Design Decisions
- Single signer, not a registry. The on-chain contract knows one address. Rotating the signer key requires a single
setSigner()call. - No on-chain budget tracking.
postOpis a no-op. The Paymaster Backend tracks gas usage off-chain, avoiding storage writes per UserOp and keeping the paymaster gas overhead minimal. validUntilin paymasterData. The backend sets an expiry (e.g., 5 minutes) to limit the window for signed paymasterData reuse.- v0.9 paymaster signature format. Standard suffix with magic bytes, parsed by OZ helpers. Compatible with EntryPoint v0.9 tooling.
4. Paymaster Backend
An off-chain HTTP service that holds SK_paymaster (the private key corresponding to the on-chain signer address) and implements the partner authentication model.
Responsibilities
- Sender validation — reject UserOps where
senderis not the configured SharedAccount address. - Partner authentication — validate partner credentials (ECDSA signature over UserOp fields) on each request. Skipped in open sponsorship mode.
- Operation allowlisting — decode
userOp.callDataand verify the target is an allowed EBEMT contract and the selector passes the configured selector policy. - Budget enforcement — track per-partner gas usage, reject requests exceeding the partner's budget. Skipped in open sponsorship mode.
- Rate limiting — per-partner and per-IP rate limits. Skipped in open sponsorship mode.
- Signing — sign
keccak256(abi.encode(userOpHash, validUntil))withSK_paymasterand return thepaymasterData.
Open Sponsorship Mode
When OPEN_SPONSORSHIP=true, the paymaster sponsors all valid UserOps regardless of partner credentials. Partner authentication, budget enforcement, and rate limiting are all skipped. Only sender validation and callData allowlisting are enforced. Useful for dev, testnet, and early production environments where partner infrastructure is not yet set up.
API (ERC-7677)
The Paymaster Backend exposes a single JSON-RPC endpoint at POST / implementing the ERC-7677 Paymaster Web Service standard, plus a REST health endpoint.
All ERC-7677 methods take params [userOp, entryPoint, chainId, context] where context carries partner credentials:
interface PaymasterContext {
partnerId: string;
partnerSignature: Hex; // Partner signs keccak256(abi.encode(sender, nonce, keccak256(callData)))
}
pm_getPaymasterStubData
Called before gas estimation. Returns stub paymaster data (with a dummy signature) so the bundler can estimate gas accurately. Validates that the sender is the SharedAccount and that the partner exists, but skips partner signature verification — stub data is only used for gas estimation, not signing.
Gas fields in the UserOp are optional (may be zero or absent per ERC-7677).
Response (v0.7+ format):
{
paymaster: Address; // EBPaymaster address
paymasterData: Hex; // validUntil(6) + dummySig(65) + sigLen(2) + magic(8)
paymasterVerificationGasLimit: Hex; // e.g. "0x030d40" (200K)
paymasterPostOpGasLimit: Hex; // e.g. "0x00c350" (50K)
sponsor?: { name: string; icon?: string };
isFinal?: boolean; // false — wallet must call pm_getPaymasterData
}
pm_getPaymasterData
Called after gas estimation with the UserOp including updated gas values. Returns the final signed paymaster data. Validates that the sender is the SharedAccount.
Before signing, the backend optionally simulates the operation via eth_call. Single-call UserOps are simulated as the inner call from the SharedAccount to the target contract. ERC-7821 batch UserOps are simulated as one call from the EntryPoint to the SharedAccount with the original batch calldata. This catches invalid ZK proofs, expired EIP-712 signatures, and insufficient balances before consuming partner budget. Enabled by default (SIMULATE_BEFORE_SIGNING=true); disable for testing or if latency is critical.
Response:
{
paymaster: Address; // EBPaymaster address
paymasterData: Hex; // validUntil(6) + realSig(65) + sigLen(2) + magic(8)
}
eb_requestGasAndPaymasterData
All-in-one endpoint (similar to Alchemy's alchemy_requestGasAndPaymasterAndData). Estimates gas via the bundler and returns signed paymaster data in a single call. Solves the circular dependency where the bundler needs a valid paymaster signature to estimate gas (bundlers like Rundler validate the paymaster signature during eth_estimateUserOperationGas).
Flow:
- Fill missing gas limit fields with generous defaults (5M callGas, 1M verificationGas, 200K preVerificationGas)
- Nonce swap: Replace the nonce with
0for estimation so the estimation-signed UserOp can't be submitted on-chain (SharedAccount rejects mismatched nonce keys) - Sign with the real paymaster key and call the bundler's
eth_estimateUserOperationGas - Restore the original nonce and apply estimated gas values
- If enabled, simulate the final UserOp shape before sponsorship
- Atomically record budget usage
- Sign and return gas estimates + final signed paymaster data
Gas limit fields in the UserOp are optional — the endpoint fills them with defaults if zero/absent.
Response:
{
paymaster: Address;
paymasterData: Hex; // Final signed paymaster data
paymasterVerificationGasLimit: Hex;
paymasterPostOpGasLimit: Hex;
callGasLimit: Hex; // Estimated by bundler
verificationGasLimit: Hex; // Estimated by bundler
preVerificationGas: Hex; // Estimated by bundler
}
JSON-RPC Error Codes
| Code | Meaning |
|---|---|
-32000 | Internal server error |
-32001 | Unknown partner or invalid signature |
-32002 | Partner gas budget exceeded |
-32003 | Rate limited (sliding window: requests per partner in last 60s) |
-32004 | Disallowed operation (target or selector not in allowlist) |
-32005 | Duplicate UserOperation reservation |
-32600 | Invalid request parameters |
GET /api/health (non-ERC-7677, REST)
Returns Paymaster Backend health. Not part of the ERC-7677 standard — this is a custom monitoring endpoint.
Response:
{
"status": "ok",
"signer": "0x...",
"paymaster": "0x...",
"partners_count": 3
}
Partner Registry
Stored in Postgres. Each partner record:
interface Partner {
id: string;
publicKey: Address; // For verifying partner request signatures
budgetWei: bigint; // Lifetime gas budget in wei (0 = unlimited)
usedWei: bigint; // Gas consumed so far (reconciler adjusts for overestimates)
rateLimit: number; // Max requests per minute (0 = unlimited). Sliding window over usage_log.
allowedContracts: Address[]; // Per-partner contract allowlist (empty = use global only)
active: boolean;
}
Contract allowlisting is two-tiered: global ALLOWED_CONTRACTS (env config) applies to all requests, then per-partner allowed_contracts further restricts which contracts a specific partner can target. Selector allowlisting is global only (ALLOWED_SELECTORS). Empty ALLOWED_SELECTORS means all selectors are allowed; malformed configured selectors fail startup.
Gas Tracking
Each signing request creates a usage_log entry with:
| Field | Description |
|---|---|
user_op_hash | Links the entry to an on-chain UserOperationEvent |
reservation_key | Stable duplicate-detection key over chain, EntryPoint, paymaster, sender, nonce, and keccak256(callData) |
estimated_gas_wei | (callGasLimit + verificationGasLimit + preVerificationGas + paymasterOverhead) × maxFeePerGas |
actual_gas_wei | Filled later by the reconciliation job (nullable until settled) |
valid_until | Paymaster signature expiry timestamp — entries not settled before this are candidates for expiry |
status | pending → settled (confirmed on-chain) / expired (validUntil passed, no on-chain event) / failed (on-chain revert) |
Flow:
- At signing time (
pm_getPaymasterData): insert withstatus = 'pending', deductestimated_gas_weifrom the partner'sused_wei. A second request with the samereservation_keyis rejected while the earlier entry is pending, settled, or failed, so one logical UserOp cannot reserve budget repeatedly. Expired entries may be retried. Pre-upgrade pending rows without areservation_keyblock new reservations for that partner until they settle or expire. - Reconciliation job (background loop, every
RECONCILER_INTERVAL_SECS-- default 30s):- Scans up to the block indicated by
RECONCILER_BLOCK_TAG(defaultfinalized) to avoid settling entries based on blocks that could be reorged out. - Fetches
UserOperationEventlogs from EntryPoint viaeth_getLogs, filtered bypaymaster = EBPaymaster. - Processes blocks in batches of 1000, tracking the last synced block in a
reconciler_statetable (survives restarts). - Matches each event's
userOpHashto ausage_logentry. - Success (
event.success = true): setstatus = 'settled', recordactual_gas_wei = event.actualGasCost, refundestimated - actualto partner'sused_wei. - Failure (
event.success = false): setstatus = 'failed', recordactual_gas_wei = event.actualGasCost, refundestimated - actualto partner'sused_wei. - All settle/expire operations are idempotent (
WHERE status = 'pending'guard).
- Scans up to the block indicated by
- Expiry sweep (runs after each reconciliation pass): entries still
pendingpastvalid_until + RECONCILER_EXPIRY_GRACE_SECS(default 600s), measured against the timestamp of the scanned reconciliation head, are markedstatus = 'expired'with full refund ofestimated_gas_wei.
Configuration:
| Env var | Default | Description |
|---|---|---|
RECONCILER_INTERVAL_SECS | 30 | Polling interval |
RECONCILER_BLOCK_TAG | finalized | Block tag to scan up to: finalized, safe, or latest. Use finalized on chains with finality (Base, OP Stack). Use latest on devnets (anvil) that lack finalized block support. |
RECONCILER_EXPIRY_GRACE_SECS | 600 | Grace period after valid_until before expiring |
RECONCILER_START_BLOCK | 0 (= current head) | Block to start scanning from on first run. On subsequent runs, resumes from the last synced block (persisted in reconciler_state table). |
Implementation: See crates/paymaster-backend/src/reconciler.rs.
5. EBEMT Contract Gasless Functions
publicToEncryptedTransferWithAuth was added to enable gasless public to encrypted transfers (see Section 6 for UserOp construction). All encrypted operations work with the SharedAccount:
| Function | Why it works |
|---|---|
encryptedToPublicTransfer | No msg.sender check. Authorization via ZK proof + controller EIP-712 signature. |
encryptedTransfer | No msg.sender check. Authorization via ZK proof + controller EIP-712 signature. |
activatePending | No msg.sender check. Authorization via controller EIP-712 signature. |
registerEpk | No msg.sender check. Authorization via ZK proof binding controller address. |
changeController | No msg.sender check. Authorization via current controller's EIP-712 signature. |
transferWithAuthorization | EIP-3009. No msg.sender check. Authorization via owner EIP-712 signature; recipient is bound into the digest. |
receiveWithAuthorization | EIP-3009. Requires msg.sender == to for pull-style flows; recipient bound into the digest. |
cancelAuthorization | EIP-3009. Owner pre-emptively burns an unused authorization nonce. |
6. UserOperation Construction
All UserOperations use sender = SharedAccount. For single-call operations, callData starts with the executeUserOp selector (0x8dd7712f) followed by abi.encode(target, value, innerCallData). The EntryPoint detects this selector and calls executeUserOp(userOp, userOpHash), which decodes the payload from callData[4:]. For batch operations, callData encodes the ERC-7821 execute(mode, executionData) call.
Single Call: Public to Encrypted Transfer
User signs an EIP-712 permit off-chain, then builds a UserOp. The permit uses non-sequential bitmap nonces (caller picks any unused nonce) and a bytes signature (compatible with both EOA and ERC-1271 smart contract signers):
const innerCallData = encodeFunctionData({
abi: ebemtAbi,
functionName: 'publicToEncryptedTransferWithAuth',
args: [owner, recipient, amount, nonce, deadline, signature],
});
// callData = executeUserOp.selector + abi.encode(target, value, data)
// The EntryPoint detects the selector and calls executeUserOp(userOp, hash).
// SharedAccount decodes (target, value, data) from callData[4:].
const executePayload = encodeAbiParameters(
[{ type: 'address' }, { type: 'uint256' }, { type: 'bytes' }],
[ebemtAddress, 0n, innerCallData],
);
const callData = concat(['0x8dd7712f', executePayload]);
Single Call: Encrypted to Public Transfer
User generates a ZK proof locally, then signs a controller EIP-712 authorization (EncryptedToPublicAuth):
const innerCallData = encodeFunctionData({
abi: ebemtAbi,
functionName: 'encryptedToPublicTransfer',
args: [proof, senderEpk, newBalance, amount, recipient,
clearPending, deactivatePending,
{ nonce, deadline, signature: controllerSignature }],
});
const executePayload = encodeAbiParameters(
[{ type: 'address' }, { type: 'uint256' }, { type: 'bytes' }],
[ebemtAddress, 0n, innerCallData],
);
const callData = concat(['0x8dd7712f', executePayload]);
Single Call: Transfer (Encrypted → Encrypted)
User generates a ZK proof locally, then signs a controller EIP-712 authorization (EncryptedTransferAuth):
const innerCallData = encodeFunctionData({
abi: ebemtAbi,
functionName: 'encryptedTransfer',
args: [
proof, senderEpk, newSenderBalance, transferAmount,
trcCiphertext, recipientEpk,
clearPending, deactivatePending,
{ nonce, deadline, signature: controllerSignature },
],
});
const executePayload = encodeAbiParameters(
[{ type: 'address' }, { type: 'uint256' }, { type: 'bytes' }],
[ebemtAddress, 0n, innerCallData],
);
const callData = concat(['0x8dd7712f', executePayload]);
Batch Call (ERC-7821)
Multiple operations in one UserOp via execute(mode, executionData):
const batchMode = '0x0100000000000000000000000000000000000000000000000000000000000000';
const calls = [
{ target: ebemtAddress, value: 0n, data: publicToEncryptedCallData },
{ target: ebemtAddress, value: 0n, data: transferCallData },
];
const callData = encodeFunctionData({
abi: sharedAccountAbi,
functionName: 'execute',
args: [batchMode, encodeAbiParameters([{ type: 'tuple[]', components: [
{ type: 'address', name: 'target' },
{ type: 'uint256', name: 'value' },
{ type: 'bytes', name: 'callData' },
]}], [calls])],
});
Common UserOp Fields
interface EBUserOperation {
sender: Address; // SharedAccount address (constant)
nonce: bigint; // nonceKey (from callData hash) << 64 | sequence
initCode: '0x'; // SharedAccount already deployed
callData: Hex; // As constructed above
callGasLimit: bigint; // See gas estimation section
verificationGasLimit: bigint; // ~100K (SharedAccount + Paymaster validation)
preVerificationGas: bigint; // Bundler-estimated
maxFeePerGas: bigint; // From gas oracle
maxPriorityFeePerGas: bigint; // From gas oracle
paymasterAndData: Hex; // From Paymaster Backend
signature: '0x'; // SharedAccount doesn't check signatures
}
Nonce Derivation
function deriveNonce(
callData: Hex,
entryPoint: Address,
sharedAccount: Address
): bigint {
const key = BigInt(keccak256(callData)) & ((1n << 192n) - 1n);
// Query current sequence from EntryPoint
const sequence = await entryPoint.read.getNonce([sharedAccount, key]);
return (key << 64n) | (sequence & ((1n << 64n) - 1n));
}
7. Gas Estimation
Per-Operation Gas (execution only)
| Operation | Estimated Gas | Breakdown |
|---|---|---|
registerEpk | 300K–500K | Proof verification (~250K) + controller storage write (~22K) + overhead |
activatePending | 30K–50K | Signature check (~7K) + nonce bitmap update (~5K–22K) + storage write (~5K) + overhead |
publicToEncryptedTransferWithAuth | 100K–150K | Signature check (~7K) + nonce update (~5K–22K) + ERC-20 burn (~15K) + ScalarBaseMul (~30K) + EC add (~20K) + storage write (~20K) + overhead |
encryptedToPublicTransfer | 300K–500K | Signature check (~7K) + proof verification (~250K) + EC operations (~40K) + ERC-20 mint (~25K) + storage write (~20K) |
encryptedTransfer | 400K–700K | Signature check (~7K) + proof verification (~250K) + 2x EC add (~40K) + 2x storage write (~40K) + event emission (~10K) + overhead |
ERC-4337 Overhead
| Phase | Estimated Gas |
|---|---|
SharedAccount validateUserOp | ~10K (hash + comparison) |
EBPaymaster validatePaymasterUserOp | ~15K (ECDSA recovery + comparison) |
| EntryPoint bookkeeping | ~30K |
| Total overhead | ~55K |
Calldata Cost on Base (L2)
Calldata dominates cost on L2s because it's posted to L1. Base uses EIP-4844 blobs but calldata is still significant for large payloads.
| Operation | Calldata Size | Estimated L1 Data Cost |
|---|---|---|
publicToEncryptedTransferWithAuth | ~350 bytes (no proof) | ~5K gas equivalent |
encryptedToPublicTransfer | ~3–5 KB (proof + 12 public inputs) | ~50K–80K gas equivalent |
encryptedTransfer | ~4–6 KB (proof + 23 public inputs) | ~65K–100K gas equivalent |
Recommended Gas Limits
| Field | Public to Encrypted | Encrypted to Public | Transfer |
|---|---|---|---|
callGasLimit | 200K | 600K | 800K |
verificationGasLimit | 100K | 100K | 100K |
preVerificationGas | Bundler-estimated | Bundler-estimated | Bundler-estimated |
These are conservative upper bounds. The bundler's eth_estimateUserOperationGas provides tighter estimates per UserOp.
Circular Dependency with Bundler Gas Estimation
Some bundlers (notably Rundler/Alchemy) validate the paymaster signature during eth_estimateUserOperationGas. This creates a circular dependency: the bundler needs a valid paymaster signature to estimate gas, but the paymaster needs final gas values to sign.
The standard ERC-7677 flow (pm_getPaymasterStubData → estimate → pm_getPaymasterData) assumes the bundler accepts dummy signatures during estimation. This works with bundlers like Alto (Pimlico) but fails with Rundler.
Solution: The eb_requestGasAndPaymasterData endpoint resolves this by estimating gas and signing server-side in a single call. It signs with generous default gas for estimation (using nonce 0 so the estimation-signed UserOp is unusable on-chain), then simulates, reserves budget, and signs with the estimated gas values for the final response. See Section 4 for details.
8. Partner Authentication Flow
All partner data lives off-chain.
Sequence
┌──────────┐ ┌──────────────┐ ┌──────────────────┐ ┌─────────┐
│ User SDK │ │Partner Backnd│ │Paymaster Backend │ │ Bundler │
└────┬─────┘ └──────┬───────┘ └────────┬─────────┘ └────┬────┘
│ │ │ │
│ 1. Build UserOp │ │ │
│ (proof/sig done) │ │ │
│ │ │ │
│ 2. Request │ │ │
│ sponsorship │ │ │
│─────────────────►│ │ │
│ │ │ │
│ │ 3. Validate user │ │
│ │ (quotas, fraud) │ │
│ │ │ │
│ │ 4. Forward UserOp │ │
│ │ + partner creds │ │
│ │─────────────────────►│ │
│ │ │ │
│ │ │ 5. Validate partner│
│ │ │ Check budget │
│ │ │ Decode callData │
│ │ │ Verify target │
│ │ │ Sign paymaster │
│ │ │ data │
│ │ │ │
│ │ 6. paymasterAndData │ │
│ │◄─────────────────────│ │
│ │ │ │
│ 7. paymasterData │ │ │
│◄─────────────────│ │ │
│ │ │ │
│ 8. Submit UserOp │ │ │
│ (complete) │ │ │
│─────────────────────────────────────────────────────────────►│
│ │ │ │
│ 9. UserOpHash │ │ │
│◄─────────────────────────────────────────────────────────────│
Partner Credential Format
Partners authenticate to the Paymaster Backend via ECDSA request signature:
// Partner signs a hash of the UserOp fields they're sponsoring.
// Uses abi.encode (not encodePacked) to avoid hash collision risk.
// personalSign adds the EIP-191 prefix automatically.
const partnerSigPayload = keccak256(
encodeAbiParameters(
[{ type: 'address' }, { type: 'uint256' }, { type: 'bytes32' }],
[userOp.sender, userOp.nonce, keccak256(userOp.callData)]
)
);
const partnerSignature = personalSign(partnerSigPayload, SK_partner);
The Paymaster Backend recovers the signer address (with EIP-191 prefix) and matches it against the partner's registered publicKey.
Budget Model
| Parameter | Description | Example |
|---|---|---|
budgetWei | Lifetime gas budget in wei (0 = unlimited) | 1 ETH |
allowedContracts | EBEMT addresses (global config, not per-partner) | [zkUSD, zkEUR] |
Budget check and deduction are atomic. The backend inserts a pending reservation and increments used_wei in one database transaction, rejecting either over-budget requests or duplicate reservation keys before signing (see Security: Budget race condition). Actual gas usage is reconciled asynchronously from UserOperationEvent logs by the reconciler, which refunds the difference between estimated and actual cost.
9. Bundler Configuration
Provider
Use a third-party bundler on Base. Recommended providers (all offer free tiers):
| Provider | Endpoint | Notes |
|---|---|---|
| Coinbase CDP | https://api.developer.coinbase.com/rpc/v1/base/<KEY> | Native Base support, 0.25 ETH gas credits + up to $15K in credits on signup |
| Pimlico | https://api.pimlico.io/v2/base/rpc?apikey=<KEY> | Widely used, good ERC-4337 tooling |
| Alchemy | https://base-mainnet.g.alchemy.com/v2/<KEY> | Broad chain coverage |
Coinbase CDP is the recommended default — Base is Coinbase's chain, so the integration is first-class and the free tier is generous. All providers require a free account signup for an API key. Self-hosting (e.g., Skandha, Rundler) is an option if third-party dependency is unacceptable.
ERC-4337 RPC Methods
| Method | Purpose |
|---|---|
eth_sendUserOperation | Submit a UserOp to the bundler mempool |
eth_estimateUserOperationGas | Estimate gas limits for a UserOp |
eth_getUserOperationByHash | Query UserOp status by hash |
eth_getUserOperationReceipt | Get execution receipt (success/revert, gas used) |
eth_supportedEntryPoints | Confirm EntryPoint v0.9 support |
EntryPoint
EntryPoint v0.9: 0x433709009B8330FDa32311DF1C2AFA402eD8D009
This is the version targeted by OpenZeppelin Contracts 5.6.x. Verify deployment on the target chain before launch.
Fallback
If the primary bundler is unreachable:
- Try the secondary bundler provider.
- If both are down, the SDK surfaces an error. Users can retry later.
- No data is lost — the UserOp was never submitted, and proofs/signatures remain valid until their expiry.
10. SDK Integration
Replacing the Relayer Client
The current SDK uses a RelayerClient that makes HTTP calls to the relayer. Replace with:
interface BundlerClient {
/** Submit a publicToEncryptedTransfer operation (public → encrypted) */
publicToEncryptedTransfer(params: {
amount: bigint;
epk: Point;
owner: Address;
deadline: bigint;
signature: { v: number; r: Hex; s: Hex };
}): Promise<Hex>; // returns UserOp hash
/** Submit an encryptedToPublicTransfer operation (encrypted → public) */
encryptedToPublicTransfer(params: {
proof: Hex;
senderEpk: Point;
newBalance: ElGamalCiphertext;
amount: bigint;
recipient: Address;
}): Promise<Hex>;
/** Submit a transfer operation (encrypted → encrypted) */
transfer(params: {
proof: Hex;
senderEpk: Point;
newSenderBalance: ElGamalCiphertext;
transferAmount: ElGamalCiphertext;
trcCiphertext: ElGamalCiphertext;
recipientEpk: Point;
}): Promise<Hex>;
/** Check UserOp status */
getReceipt(userOpHash: Hex): Promise<UserOperationReceipt | null>;
}
Internal Flow (per operation, ERC-7677)
async function submitUserOp(callData: Hex): Promise<Hex> {
// 1. Derive nonce
const nonceKey = BigInt(keccak256(callData)) & ((1n << 192n) - 1n);
const nonce = await entryPoint.read.getNonce([SHARED_ACCOUNT, nonceKey]);
// 2. Build unsigned UserOp (no paymaster data yet)
const userOp = {
sender: SHARED_ACCOUNT,
nonce,
callData,
signature: '0x',
// gas fields placeholder...
};
// 3. Get stub paymaster data (ERC-7677 step 1)
const stubResult = await paymasterBackend.request({
method: 'pm_getPaymasterStubData',
params: [userOp, ENTRYPOINT, chainIdHex, {
partnerId: PARTNER_ID,
partnerSignature: signPartnerPayload(userOp),
}],
});
userOp.paymaster = stubResult.paymaster;
userOp.paymasterData = stubResult.paymasterData;
userOp.paymasterVerificationGasLimit = stubResult.paymasterVerificationGasLimit;
userOp.paymasterPostOpGasLimit = stubResult.paymasterPostOpGasLimit;
// 4. Estimate gas via bundler (with stub paymaster data)
const gasEstimate = await bundler.request({
method: 'eth_estimateUserOperationGas',
params: [userOp, ENTRYPOINT],
});
Object.assign(userOp, gasEstimate);
// 5. Get final signed paymaster data (ERC-7677 step 2)
const finalResult = await paymasterBackend.request({
method: 'pm_getPaymasterData',
params: [userOp, ENTRYPOINT, chainIdHex, {
partnerId: PARTNER_ID,
partnerSignature: signPartnerPayload(userOp),
}],
});
userOp.paymaster = finalResult.paymaster;
userOp.paymasterData = finalResult.paymasterData;
// 6. Submit to bundler
const userOpHash = await bundler.request({
method: 'eth_sendUserOperation',
params: [userOp, ENTRYPOINT],
});
return userOpHash;
}
Constants
const SHARED_ACCOUNT = '0x...'; // Deterministic CREATE2 address
const ENTRYPOINT = '0x433709009B8330FDa32311DF1C2AFA402eD8D009';
const PAYMASTER = '0x...'; // Deployed EBPaymaster address
11. Deployment & Operations
Deployment Order
- SharedAccount — deploy via
CREATE2with fixed salt. Record the deterministic address. - EBPaymaster — deploy with
ownerandsigner(Paymaster Backend's address). - Stake Paymaster — call
EBPaymaster.addStake{value: X}(unstakeDelaySec)to register with EntryPoint. - Fund Paymaster — call
EBPaymaster.deposit{value: X}()to deposit gas funds at EntryPoint. - Paymaster Backend — deploy the off-chain service, configure
SK_paymaster, register initial partners. - SDK update — configure the SDK with SharedAccount address, Paymaster address, Paymaster Backend URL, and bundler RPC URL.
Configuration (Paymaster Backend)
# Paymaster signing key (CRITICAL — protect this)
PAYMASTER_PRIVATE_KEY=0x...
# On-chain addresses
SHARED_ACCOUNT_ADDRESS=0x...
PAYMASTER_ADDRESS=0x...
ENTRYPOINT_ADDRESS=0x433709009B8330FDa32311DF1C2AFA402eD8D009
# Allowed EBHub / EBEMT contracts
ALLOWED_CONTRACTS=0x...,0x...,0x...
# Optional allowed function selectors (4-byte hex). Empty means all selectors.
# Startup fails if this contains any malformed entry.
# registerEpk = 0x7added76
# changeController = 0x1476ba52
# activatePending = 0xea96beff
# publicToEncryptedTransfer = 0x7034d1a7
# publicToEncryptedTransferWithAuth = 0x25fe7115
# encryptedToPublicTransfer = 0x9c5ccf15
# encryptedTransfer = 0xf5529bcc
# publicMint (test faucet only) = 0x36fac067
ALLOWED_SELECTORS=0x7added76,0x1476ba52,0xea96beff,0x7034d1a7,0x25fe7115,0x9c5ccf15,0xf5529bcc
# Bundler RPC (for gas estimation relay)
BUNDLER_RPC_URL=https://api.pimlico.io/v2/base/rpc?apikey=...
# Chain
CHAIN_ID=8453
RPC_URL=https://...
# Signing
PAYMASTER_DATA_VALIDITY_SECONDS=300 # 5 minutes
SIMULATE_BEFORE_SIGNING=true # eth_call simulation before signing (disable for testing)
# Sponsorship
OPEN_SPONSORSHIP=false # Skip partner auth + budget (true for dev/testnet)
# Database
DATABASE_URL=postgres://user:pass@host:5432/paymaster
For a test faucet deployment, include the testnet EBHub address in the allowed contract set because faucet mint calls target EBHub.publicMint. If the same backend also sponsors direct token operations, include the testnet zkUSD, zkEUR, zkGBP, and zkPLN EBEMT addresses. If selector allowlisting is enabled, also add 0x36fac067; do not enable this selector for the main deployment.
Monitoring
| Metric | Alert Threshold | Action |
|---|---|---|
| Paymaster EntryPoint deposit | < 0.1 ETH | Top up deposit |
| Paymaster Backend uptime | < 99.9% | Page on-call |
| Per-partner gas usage | > 90% of budget | Notify partner |
| Bundler error rate | > 5% of submissions | Switch to fallback bundler |
UserOperationRevertReason events | Any | Investigate (likely gas limit or contract revert) |
12. Security Model
Threat: Griefing (gas drain)
Mitigation: The Paymaster Backend signs paymasterData only after validating partner credentials and checking budgets. An attacker without valid partner credentials cannot cause gas spending. An attacker who compromises a partner's key is limited by that partner's budget.
Threat: SK_paymaster compromise
Impact: An attacker with SK_paymaster can sign arbitrary paymasterData, draining the Paymaster's EntryPoint deposit.
Mitigation:
- Store
SK_paymasterin a secure enclave or KMS (e.g., AWS KMS with IAM policies). - The on-chain
signercan be rotated instantly viasetSigner()— the Paymaster Backend generates a new key, owner callssetSigner(newAddress). - Set
validUntilto short windows (5 minutes) to limit the damage window. - Monitor EntryPoint deposit balance for unexpected drops.
Threat: Malicious callData
Mitigation: The Paymaster Backend decodes userOp.callData before signing and verifies:
- For single calls (via
executeUserOp): decodesabi.encode(target, value, data)— checkstargetis an allowed EBEMT contract,valueis 0, and the inner function selector is in the allowlist. - For batch calls (via ERC-7821): rejects unsupported execution modes, decodes the
Execution[]array, and validates each call's target, zero value, and selector.
Threat: Nonce front-running
Non-issue: Each UserOp's nonce key is derived from its callData. Two different UserOps (different proofs/signatures) use different nonce lanes and cannot interfere with each other.
Threat: Batch call extraction (front-running)
The SharedAccount is ownerless — there is no signature binding the batch together. An observer who sees a pending UserOp in the mempool could extract individual calls (signatures, proofs) from a batch and submit them separately through the SharedAccount.
Impact: If a batch is meant to be atomic (e.g., publicToEncryptedTransfer + swap where the user expects something in return), a front-runner could execute the publicToEncryptedTransfer alone, consuming the EIP-712 signature. The user's full batch then fails.
Mitigations (layered):
- Paymaster Backend as gatekeeper. The front-runner needs signed
paymasterDatafrom the backend to sponsor their UserOp. Without valid partner credentials, the extraction is useless. - Controller signature binding. Each encrypted operation requires a controller EIP-712 signature that binds all parameters (including the proof, ciphertexts, and pending flags) via
paramsHash. An extracted proof cannot be replayed with different parameters. The nonce in the controller signature provides additional replay protection. - Private bundler submission. Submit UserOps via private channels (e.g., Flashbots Protect equivalent for ERC-4337) so pending UserOps are not visible in the mempool.
- Partner trust. A malicious partner could front-run their own users. The partner backend controls submission ordering — this is a trust assumption inherent in the partner model.
Guidance for SDK users: If a batch operation must be atomic (the user loses value if only part executes), use private submission. For independent operations batched purely for gas savings, extraction is harmless — each call produces the same outcome regardless of who submits it, and the controller signature ensures parameters cannot be tampered with.
Threat: Budget race condition
Mitigation: Budget check and usage recording happen in one database transaction. The backend rejects reservation_key values that are already pending, settled, or failed, then inserts a pending usage_log row keyed by reservation_key; the partial unique index on pending reservation keys rejects concurrent duplicates. It then increments used_wei only if the new value stays within budget_wei.
If either check fails, the transaction is rolled back and the request is rejected before signing. This eliminates the race — concurrent requests from the same partner serialize at the database level. The reconciliation job (see Gas Tracking) later refunds the difference between estimated and actual gas, so partners are not permanently overcharged.
Threat: Rate limit exhaustion via replayed partner signatures
The partner signature covers (sender, nonce, keccak256(callData)) but does not include a timestamp. An attacker who observes a previously signed request can replay it against the Paymaster Backend. Exact replays do not create additional usage_log rows or budget reservations; they may still be rejected by the rate-limit check first if the partner is already at its limit. Replays of requests that never reached reservation can still consume rate-limit capacity.
Current mitigations:
- The replay window is limited by how long the attacker retains a valid signed request. Partner backends should use short-lived credentials and rotate signatures.
- Per-IP rate limiting at the reverse proxy layer (nginx/Cloudflare) limits a single attacker's throughput.
Threat: Bundler censorship
Mitigation: Switch to an alternative bundler. The UserOp format is standard — any ERC-4337 bundler can process it.
Partner Isolation
Partners cannot affect each other:
- Separate budgets, separate rate limits
- A partner exceeding its budget gets rejected; other partners are unaffected
- Partner credentials are independent — compromising one partner's key does not affect others