Skip to main content
Version: 0.3.0

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

  1. System Architecture
  2. SharedAccount Contract
  3. EBPaymaster Contract
  4. Paymaster Backend
  5. EBEMT Contract Gasless Functions
  6. UserOperation Construction
  7. Gas Estimation
  8. Partner Authentication Flow
  9. Bundler Configuration
  10. SDK Integration
  11. Deployment & Operations
  12. 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

ComponentResponsibilityState
SharedAccountGeneric ownerless ERC-4337 account; forwards calls to EBEMTOn-chain, stateless (nonces managed by EntryPoint)
EBPaymasterVerifies backend signature, pays gas via EntryPoint depositOn-chain, minimal (signer address + deposit)
Paymaster BackendPartner auth, budget enforcement, operation allowlisting, signs paymasterDataOff-chain, stateful (partner registry, usage DB)
BundlerCollects UserOps, submits to EntryPointThird-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 validateUserOp function 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. validateUserOp reverts if paymasterAndData is 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 callData off-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's callData starts with the executeUserOp selector followed by abi.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 OZ ERC7821 base contract with _erc7821AuthorizedExecutor restricted 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's ERC7821
  • validateUserOp checks nonceKey == uint192(keccak256(callData))
  • executeUserOp decodes abi.encode(target, value, data) from userOp.callData
  • execute(mode, executionData) via ERC-7821 for batch operations
  • _erc7821AuthorizedExecutor restricted 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() and userOp.paymasterSignature() from OZ's ERC4337Utils
  • Uses ERC4337Utils.packValidationData(sigValid, 0, validUntil) to pack the return value
  • postOp is 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. postOp is a no-op. The Paymaster Backend tracks gas usage off-chain, avoiding storage writes per UserOp and keeping the paymaster gas overhead minimal.
  • validUntil in 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

  1. Sender validation — reject UserOps where sender is not the configured SharedAccount address.
  2. Partner authentication — validate partner credentials (ECDSA signature over UserOp fields) on each request. Skipped in open sponsorship mode.
  3. Operation allowlisting — decode userOp.callData and verify the target is an allowed EBEMT contract and the selector passes the configured selector policy.
  4. Budget enforcement — track per-partner gas usage, reject requests exceeding the partner's budget. Skipped in open sponsorship mode.
  5. Rate limiting — per-partner and per-IP rate limits. Skipped in open sponsorship mode.
  6. Signing — sign keccak256(abi.encode(userOpHash, validUntil)) with SK_paymaster and return the paymasterData.

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:

  1. Fill missing gas limit fields with generous defaults (5M callGas, 1M verificationGas, 200K preVerificationGas)
  2. Nonce swap: Replace the nonce with 0 for estimation so the estimation-signed UserOp can't be submitted on-chain (SharedAccount rejects mismatched nonce keys)
  3. Sign with the real paymaster key and call the bundler's eth_estimateUserOperationGas
  4. Restore the original nonce and apply estimated gas values
  5. If enabled, simulate the final UserOp shape before sponsorship
  6. Atomically record budget usage
  7. 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

CodeMeaning
-32000Internal server error
-32001Unknown partner or invalid signature
-32002Partner gas budget exceeded
-32003Rate limited (sliding window: requests per partner in last 60s)
-32004Disallowed operation (target or selector not in allowlist)
-32005Duplicate UserOperation reservation
-32600Invalid 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:

FieldDescription
user_op_hashLinks the entry to an on-chain UserOperationEvent
reservation_keyStable duplicate-detection key over chain, EntryPoint, paymaster, sender, nonce, and keccak256(callData)
estimated_gas_wei(callGasLimit + verificationGasLimit + preVerificationGas + paymasterOverhead) × maxFeePerGas
actual_gas_weiFilled later by the reconciliation job (nullable until settled)
valid_untilPaymaster signature expiry timestamp — entries not settled before this are candidates for expiry
statuspendingsettled (confirmed on-chain) / expired (validUntil passed, no on-chain event) / failed (on-chain revert)

Flow:

  1. At signing time (pm_getPaymasterData): insert with status = 'pending', deduct estimated_gas_wei from the partner's used_wei. A second request with the same reservation_key is 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 a reservation_key block new reservations for that partner until they settle or expire.
  2. Reconciliation job (background loop, every RECONCILER_INTERVAL_SECS -- default 30s):
    • Scans up to the block indicated by RECONCILER_BLOCK_TAG (default finalized) to avoid settling entries based on blocks that could be reorged out.
    • Fetches UserOperationEvent logs from EntryPoint via eth_getLogs, filtered by paymaster = EBPaymaster.
    • Processes blocks in batches of 1000, tracking the last synced block in a reconciler_state table (survives restarts).
    • Matches each event's userOpHash to a usage_log entry.
    • Success (event.success = true): set status = 'settled', record actual_gas_wei = event.actualGasCost, refund estimated - actual to partner's used_wei.
    • Failure (event.success = false): set status = 'failed', record actual_gas_wei = event.actualGasCost, refund estimated - actual to partner's used_wei.
    • All settle/expire operations are idempotent (WHERE status = 'pending' guard).
  3. Expiry sweep (runs after each reconciliation pass): entries still pending past valid_until + RECONCILER_EXPIRY_GRACE_SECS (default 600s), measured against the timestamp of the scanned reconciliation head, are marked status = 'expired' with full refund of estimated_gas_wei.

Configuration:

Env varDefaultDescription
RECONCILER_INTERVAL_SECS30Polling interval
RECONCILER_BLOCK_TAGfinalizedBlock 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_SECS600Grace period after valid_until before expiring
RECONCILER_START_BLOCK0 (= 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:

FunctionWhy it works
encryptedToPublicTransferNo msg.sender check. Authorization via ZK proof + controller EIP-712 signature.
encryptedTransferNo msg.sender check. Authorization via ZK proof + controller EIP-712 signature.
activatePendingNo msg.sender check. Authorization via controller EIP-712 signature.
registerEpkNo msg.sender check. Authorization via ZK proof binding controller address.
changeControllerNo msg.sender check. Authorization via current controller's EIP-712 signature.
transferWithAuthorizationEIP-3009. No msg.sender check. Authorization via owner EIP-712 signature; recipient is bound into the digest.
receiveWithAuthorizationEIP-3009. Requires msg.sender == to for pull-style flows; recipient bound into the digest.
cancelAuthorizationEIP-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)

OperationEstimated GasBreakdown
registerEpk300K–500KProof verification (~250K) + controller storage write (~22K) + overhead
activatePending30K–50KSignature check (~7K) + nonce bitmap update (~5K–22K) + storage write (~5K) + overhead
publicToEncryptedTransferWithAuth100K–150KSignature check (~7K) + nonce update (~5K–22K) + ERC-20 burn (~15K) + ScalarBaseMul (~30K) + EC add (~20K) + storage write (~20K) + overhead
encryptedToPublicTransfer300K–500KSignature check (~7K) + proof verification (~250K) + EC operations (~40K) + ERC-20 mint (~25K) + storage write (~20K)
encryptedTransfer400K–700KSignature check (~7K) + proof verification (~250K) + 2x EC add (~40K) + 2x storage write (~40K) + event emission (~10K) + overhead

ERC-4337 Overhead

PhaseEstimated 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.

OperationCalldata SizeEstimated 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
FieldPublic to EncryptedEncrypted to PublicTransfer
callGasLimit200K600K800K
verificationGasLimit100K100K100K
preVerificationGasBundler-estimatedBundler-estimatedBundler-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

ParameterDescriptionExample
budgetWeiLifetime gas budget in wei (0 = unlimited)1 ETH
allowedContractsEBEMT 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):

ProviderEndpointNotes
Coinbase CDPhttps://api.developer.coinbase.com/rpc/v1/base/<KEY>Native Base support, 0.25 ETH gas credits + up to $15K in credits on signup
Pimlicohttps://api.pimlico.io/v2/base/rpc?apikey=<KEY>Widely used, good ERC-4337 tooling
Alchemyhttps://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

MethodPurpose
eth_sendUserOperationSubmit a UserOp to the bundler mempool
eth_estimateUserOperationGasEstimate gas limits for a UserOp
eth_getUserOperationByHashQuery UserOp status by hash
eth_getUserOperationReceiptGet execution receipt (success/revert, gas used)
eth_supportedEntryPointsConfirm 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:

  1. Try the secondary bundler provider.
  2. If both are down, the SDK surfaces an error. Users can retry later.
  3. 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

  1. SharedAccount — deploy via CREATE2 with fixed salt. Record the deterministic address.
  2. EBPaymaster — deploy with owner and signer (Paymaster Backend's address).
  3. Stake Paymaster — call EBPaymaster.addStake{value: X}(unstakeDelaySec) to register with EntryPoint.
  4. Fund Paymaster — call EBPaymaster.deposit{value: X}() to deposit gas funds at EntryPoint.
  5. Paymaster Backend — deploy the off-chain service, configure SK_paymaster, register initial partners.
  6. 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

MetricAlert ThresholdAction
Paymaster EntryPoint deposit< 0.1 ETHTop up deposit
Paymaster Backend uptime< 99.9%Page on-call
Per-partner gas usage> 90% of budgetNotify partner
Bundler error rate> 5% of submissionsSwitch to fallback bundler
UserOperationRevertReason eventsAnyInvestigate (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_paymaster in a secure enclave or KMS (e.g., AWS KMS with IAM policies).
  • The on-chain signer can be rotated instantly via setSigner() — the Paymaster Backend generates a new key, owner calls setSigner(newAddress).
  • Set validUntil to 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:

  1. For single calls (via executeUserOp): decodes abi.encode(target, value, data) — checks target is an allowed EBEMT contract, value is 0, and the inner function selector is in the allowlist.
  2. 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):

  1. Paymaster Backend as gatekeeper. The front-runner needs signed paymasterData from the backend to sponsor their UserOp. Without valid partner credentials, the extraction is useless.
  2. 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.
  3. 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.
  4. 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