Skip to main content

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

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

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. This is the same contract used for L3 operations (ADR-005).

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 encodes abi.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 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 from ADR-004.

Responsibilities

  1. Partner authentication — validate partner credentials (ECDSA signature over UserOp fields) on each request.
  2. Operation allowlisting — decode userOp.callData and verify the target is an allowed EBEMT contract and the selector is an allowed operation (concealAmountWithSig, revealAmount, concealedTransfer).
  3. Budget enforcement — track per-partner gas usage, reject requests exceeding the partner's budget.
  4. Rate limiting — per-partner and per-IP rate limits.
  5. Signing — sign keccak256(abi.encode(userOpHash, validUntil)) with SK_paymaster and return the paymasterData.

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

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)
-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 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:

FieldDescription
user_op_hashLinks the entry to an on-chain UserOperationEvent
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.
  2. Reconciliation job (background loop, every RECONCILER_INTERVAL_SECS — default 30s):
    • 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', full refund of estimated_gas_wei 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) are marked status = 'expired' with full refund of estimated_gas_wei.

Configuration:

Env varDefaultDescription
RECONCILER_INTERVAL_SECS30Polling interval
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 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:

FunctionWhy it works
revealAmountNo 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)

OperationEstimated GasBreakdown
concealAmountWithSig100K–150KECDSA recovery (~3K) + nonce update (~5K) + ERC-20 burn (~15K) + GTable scalar mul (~30K) + EC add (~20K) + storage write (~20K) + overhead
revealAmount300K–500KProof verification (~250K) + EC operations (~40K) + ERC-20 mint (~25K) + storage write (~20K)
concealedTransfer400K–700KProof 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
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
FieldConcealRevealTransfer
callGasLimit200K600K800K
verificationGasLimit100K100K100K
preVerificationGasBundler-estimatedBundler-estimatedBundler-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

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

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

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

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)

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_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): 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):

  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. authorizedCaller field. For operations where atomicity matters (e.g., conceal inside a swap), set authorizedCaller = SharedAccount in concealAmountWithSig or concealedTransfer. 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.
  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), 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 concealAmountWithSig in EBEMT.
  • SDK adds BundlerClient alongside existing RelayerClient.
  • Feature flag: useBundler: true | false in 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 RelayerClient from SDK.