Relayer
This specification describes the ERC-4337 infrastructure that replaces the custom L2 relayer for encrypted balance operations (conceal, reveal, transfer). It covers three components: a SharedAccount contract, a Paymaster contract, and an off-chain Paymaster Backend. For architectural context and decision rationale, see ADR-012.
Table of Contents
- System Architecture
- SharedAccount Contract
- EBPaymaster Contract
- Paymaster Backend
- EBEMT Contract Changes
- UserOperation Construction
- Gas Estimation
- Partner Authentication Flow
- Bundler Configuration
- SDK Integration
- Deployment & Operations
- Security Model
- Migration Path
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 │
│ concealAmountWithSig / revealAmount / │
│ concealedTransfer │
└──────────────────────────────────────────┘
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. This is the same contract used for L3 operations (ADR-005).
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'scallDataencodesabi.encode(target, value, data)directly (no wrapper function selector). The EntryPoint calls this during the execution phase. - ERC-7821 (Minimal Batch Executor) —
execute(mode, executionData)for batch execution. Multiple calls (e.g., conceal + 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 from ADR-004.
Responsibilities
- Partner authentication — validate partner credentials (ECDSA signature over UserOp fields) on each request.
- Operation allowlisting — decode
userOp.callDataand verify the target is an allowed EBEMT contract and the selector is an allowed operation (concealAmountWithSig,revealAmount,concealedTransfer). - Budget enforcement — track per-partner gas usage, reject requests exceeding the partner's budget.
- Rate limiting — per-partner and per-IP rate limits.
- Signing — sign
keccak256(abi.encode(userOpHash, validUntil))withSK_paymasterand return thepaymasterData.
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.
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.
Before signing, the backend optionally simulates the inner call via eth_call from the SharedAccount address to the target EBEMT contract. 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.
Note: Simulation is currently only supported for single-call UserOps (via executeUserOp). ERC-7821 batch calls are not simulated — the batch encoding makes it difficult to construct a faithful eth_call. Batch validation relies on callData parsing only.
Response:
{
paymaster: Address; // EBPaymaster address
paymasterData: Hex; // validUntil(6) + realSig(65) + sigLen(2) + magic(8)
}
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) |
-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 SQLite (or Postgres for production). 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).
Gas Tracking
Each signing request creates a usage_log entry with:
| Field | Description |
|---|---|
user_op_hash | Links the entry to an on-chain UserOperationEvent |
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. - Reconciliation job (background loop, every
RECONCILER_INTERVAL_SECS— default 30s):- 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', full refund ofestimated_gas_weito partner'sused_wei. - All settle/expire operations are idempotent (
WHERE status = 'pending'guard).
- Fetches
- Expiry sweep (runs after each reconciliation pass): entries still
pendingpastvalid_until + RECONCILER_EXPIRY_GRACE_SECS(default 600s) are markedstatus = 'expired'with full refund ofestimated_gas_wei.
Configuration:
| Env var | Default | Description |
|---|---|---|
RECONCILER_INTERVAL_SECS | 30 | Polling interval |
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 services/paymaster-backend/src/reconciler.rs.
5. EBEMT Contract Gasless Functions
concealAmountWithSig was added to enable gasless concealment (see Section 6 for UserOp construction). The other two functions already work with the SharedAccount — no changes needed:
| Function | Why it works |
|---|---|
revealAmount | No msg.sender check. ZK proof is sole authorization. |
concealedTransfer with authorizedCaller=address(0) | msg.sender check skipped when authorizedCaller is zero. ZK proof authorizes. Uses the same authorizedCallerOnly modifier. |
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: Conceal (Public → Encrypted)
User signs an EIP-712 permit off-chain, then builds a UserOp:
const innerCallData = encodeFunctionData({
abi: ebemtAbi,
functionName: 'concealAmountWithSig',
args: [owner, recipient, authorizedCaller, amount, deadline, v, r, s],
});
// 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: Reveal (Encrypted → Public)
User generates a ZK proof locally:
const innerCallData = encodeFunctionData({
abi: ebemtAbi,
functionName: 'revealAmount',
args: [proof, senderBpk, newBalance, amount, recipient],
});
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:
const innerCallData = encodeFunctionData({
abi: ebemtAbi,
functionName: 'concealedTransfer',
args: [
proof, senderBpk, newSenderBalance, transferAmount,
arCiphertext, recipientBpk,
zeroAddress, // authorizedCaller = address(0)
arCiphertext, recipientBpk,
],
});
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: concealCallData },
{ 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 |
|---|---|---|
concealAmountWithSig | 100K–150K | ECDSA recovery (~3K) + nonce update (~5K) + ERC-20 burn (~15K) + GTable scalar mul (~30K) + EC add (~20K) + storage write (~20K) + overhead |
revealAmount | 300K–500K | Proof verification (~250K) + EC operations (~40K) + ERC-20 mint (~25K) + storage write (~20K) |
concealedTransfer | 400K–700K | 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 |
|---|---|---|
concealAmountWithSig | ~350 bytes (no proof) | ~5K gas equivalent |
revealAmount | ~3–5 KB (proof + 12 public inputs) | ~50K–80K gas equivalent |
concealedTransfer | ~4–6 KB (proof + 23 public inputs) | ~65K–100K gas equivalent |
Recommended Gas Limits
| Field | Conceal | Reveal | 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.
8. Partner Authentication Flow
Maps ADR-004 into the ERC-4337 architecture with all partner data 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) | [ebUSD, ebEUR] |
Budget check and deduction are atomic — a single SQL UPDATE that increments used_wei only if the result stays within budget_wei (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 conceal operation (public → encrypted) */
conceal(params: {
amount: bigint;
bpk: Point;
owner: Address;
deadline: bigint;
signature: { v: number; r: Hex; s: Hex };
}): Promise<Hex>; // returns UserOp hash
/** Submit a reveal operation (encrypted → public) */
reveal(params: {
proof: Hex;
senderBpk: Point;
newBalance: ElGamalCiphertext;
amount: bigint;
recipient: Address;
}): Promise<Hex>;
/** Submit a transfer operation (encrypted → encrypted) */
transfer(params: {
proof: Hex;
senderBpk: Point;
newSenderBalance: ElGamalCiphertext;
transferAmount: ElGamalCiphertext;
arCiphertext: ElGamalCiphertext;
recipientBpk: 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 EBEMT contracts
ALLOWED_CONTRACTS=0x...,0x...,0x...
# Allowed function selectors (4-byte hex; empty = allow all)
# concealAmountWithSig = 0x6b63913e
# revealAmount = 0xc428283e
# concealedTransfer = 0xd5789051
ALLOWED_SELECTORS=0x6b63913e,0xc428283e,0xd5789051
# 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)
# Database
DATABASE_URL=sqlite:paymaster.db?mode=rwc # or postgres://...
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) |
Future work: Add a Prometheus /metrics endpoint for Grafana-compatible dashboards. Metrics would include request counters (by method + status), per-partner gas gauges, EntryPoint deposit balance, and request latency histograms. Currently, monitoring relies on log analysis and the /api/health endpoint.
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): decodes the
Execution[]array and validates each call's target 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., conceal + swap where the user expects something in return), a front-runner could execute the conceal 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. authorizedCallerfield. For operations where atomicity matters (e.g., conceal inside a swap), setauthorizedCaller = SharedAccountinconcealAmountWithSigorconcealedTransfer. This prevents the extracted call from being submitted by any other address — but does not prevent extraction through the SharedAccount itself. Use this as defense-in-depth, not as the sole 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), ensure authorizedCaller is set and 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.
Threat: Budget race condition
Mitigation: Budget check and usage recording are atomic. The pm_getPaymasterData handler performs a single SQL UPDATE that increments used_wei only if the result stays within budget_wei:
UPDATE partners SET used_wei = used_wei + cost
WHERE id = ? AND (budget_wei = 0 OR used_wei + cost <= budget_wei)
If rows_affected = 0, the request is rejected before signing. This eliminates the race — concurrent requests from the same partner serialize at the database level (SQLite serializes writes). 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. The partner auth passes (signature is valid), and each replay increments the rate limit counter — potentially blocking legitimate requests from that partner.
The UserOp itself would never land on-chain (stale nonce), but the rate limit damage is done off-chain before nonce validation occurs.
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.
Future hardening: Add a timestamp field to the partner context, include it in the signed payload, and reject requests older than 60 seconds. This bounds the replay window to match the rate limit window.
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
13. Migration Path
Phase 1: Deploy (parallel operation)
- Deploy SharedAccount and EBPaymaster on Base.
- Deploy Paymaster Backend.
- Complete
concealAmountWithSigin EBEMT. - SDK adds
BundlerClientalongside existingRelayerClient. - Feature flag:
useBundler: true | falsein SDK config.
Phase 2: Migrate partners
- Register existing partners in Paymaster Backend.
- Partners switch their backend to call Paymaster Backend instead of relayer.
- Monitor: compare gas costs, latency, success rates between paths.
Phase 3: Decommission relayer (L2)
- Remove L2 endpoints from relayer (
/api/transfer,/api/unshield,/api/shield,/api/register,/api/autoshield). - Relayer continues operating for L3 until ADR-005 is implemented (SharedAccount for L3 uses the same infrastructure).
- Remove
RelayerClientfrom SDK.