Bundler & UserOperations
Submit, estimate, and track ERC-4337 UserOperations through the embarkAI bundler.
The bundler module provides direct access to UserOperation submission, gas estimation, and fee calculation. Most users should prefer the higher-level ServerWallet.sendUserOperation() API — use this module when you need fine-grained control over gas parameters or fee strategies.
Setup
Chain-specific helpers take a chainId and read the bundler URL and EntryPoint v0.7 address from the built-in chain config. Fee helpers take a FeeChainContext:
import { createFeeChainContext, setPublicClient } from '@embarkai/core'
const context = createFeeChainContext(994873017) // { client, bundlerUrl, chainConfig }
// Register the client for helpers that read on-chain state (nonce, base fee)
setPublicClient(context.client)Pass { client, bundlerUrl } as the second argument to use your own viem client or bundler endpoint.
UserOperationV07 Type
The module uses the ERC-4337 v0.7 UserOperation format. Every numeric field is a hex string, not a bigint. The factory and paymaster fields are all optional and replace initCode / paymasterAndData of the packed form:
import type { UserOperationV07 } from '@embarkai/core'
const userOp: UserOperationV07 = {
sender: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e',
nonce: '0x0',
callData: '0x',
callGasLimit: '0x7a120',
verificationGasLimit: '0x7a120',
preVerificationGas: '0x186a0',
maxFeePerGas: '0x3b9aca00',
maxPriorityFeePerGas: '0x5f5e100',
signature: '0x',
// optional: factory, factoryData (first operation of an undeployed account)
// optional: paymaster, paymasterData, paymasterVerificationGasLimit, paymasterPostOpGasLimit
}Use toHex from viem to convert bigint values. The snippets below reuse this userOp.
Submitting
import { sendUserOperationRaw } from '@embarkai/core'
const userOpHash = await sendUserOperationRaw(userOp, { chainId: 994873017 })sendUserOperationWithRetry retries on transient failures (nonce races, rate limits). You supply a resigner callback that produces a fresh signed UserOperationV07 for a given on-chain nonce:
import { sendUserOperationWithRetry } from '@embarkai/core'
import type { UserOperationV07 } from '@embarkai/core'
async function signUserOpAtNonce(nonce: `0x${string}`): Promise<UserOperationV07> {
// rebuild and sign the operation with the new nonce
return { ...userOp, nonce }
}
const userOpHash = await sendUserOperationWithRetry(userOp, signUserOpAtNonce, {
chainId: 994873017,
maxRetries: 3,
})Receipt
The low-level receipt helper takes the bundler URL explicitly and returns the raw eth_getUserOperationReceipt result:
import { getUserOperationReceipt } from '@embarkai/core'
import { getChainConfig } from '@embarkai/core/read'
const chainConfig = getChainConfig(994873017)!
const userOpHash = '0x…' as `0x${string}`
const receipt = await getUserOperationReceipt(userOpHash, chainConfig.bundlerUrl)
if (receipt?.success) {
console.log('TX:', receipt.receipt.transactionHash)
}Gas Estimation
import { estimateUserOperationGas } from '@embarkai/core'
const gasEstimate = await estimateUserOperationGas(userOp, {
chainId: 994873017,
retryWithoutFactory: false, // true: retry with factory fields stripped if the account already exists
})
// gasEstimate: { callGasLimit, verificationGasLimit, preVerificationGas } as hex stringsestimateUserOperationGasWithDynamicFees builds the operation from UserOperationParams, estimates gas and applies the chosen fee strategy in one call:
import { estimateUserOperationGasWithDynamicFees, createFeeChainContext } from '@embarkai/core'
const context = createFeeChainContext(994873017)
const result = await estimateUserOperationGasWithDynamicFees(
{
sender: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e',
nonce: '0x0',
callData: '0x',
feeType: 'standard', // 'economy' | 'standard' | 'fast'
},
context,
)
// result: gas limits plus calculatedFees with maxFeePerGas / maxPriorityFeePerGasestimateUserOperationGasWithSignature(userOp, getSignature, getUserOpHash, options) runs the estimate with a real signature instead of the placeholder one. It is used internally by ServerWallet; prefer estimateUserOperationGas in your own code.
Simulation
Dry-run a UserOperation to see asset changes and revert reasons before submitting. Both helpers use a Lumia-specific bundler extension and are marked @experimental:
import { simulateUserOperation, validateUserOperation } from '@embarkai/core'
const simulation = await simulateUserOperation(userOp, { chainId: 994873017 })
if (simulation.error) {
console.error('Would revert:', simulation.error.message)
} else {
for (const change of simulation.changes) {
console.log(change.assetType, change.changeType, change.from, '→', change.to)
}
}
const validation = await validateUserOperation(userOp, { chainId: 994873017 })
console.log('Valid:', validation.valid, validation.error)Fee Calculation
All fee helpers take the FeeChainContext from above:
| Strategy | Function | Description |
|---|---|---|
| Standard | calculateDynamicFees(config, context) | EIP-1559 fees from the current base fee and bundler priority fee; config defaults to DEFAULT_FEE_CONFIG |
| Fast | calculateFastFees(context) | Larger buffers for faster inclusion |
| Economy | calculateEconomyFees(context) | Smaller buffers, longer confirmation time |
| Legacy | calculateLegacyFees(bufferPercent, context) | Pre-EIP-1559 gas price plus a percentage buffer; used automatically on chains without EIP-1559 support |
import {
calculateDynamicFees,
calculateFastFees,
calculateEconomyFees,
getCurrentBaseFee,
getFeeEstimates,
CONSERVATIVE_FEE_CONFIG,
createFeeChainContext,
} from '@embarkai/core'
const context = createFeeChainContext(994873017)
// Current base fee in wei
const baseFee = await getCurrentBaseFee(context)
// Calculate fees with a specific strategy
const standardFees = await calculateDynamicFees(undefined, context)
const conservativeFees = await calculateDynamicFees(CONSERVATIVE_FEE_CONFIG, context)
const fastFees = await calculateFastFees(context)
const economyFees = await calculateEconomyFees(context)
// All three strategies at once
const estimates = await getFeeEstimates(context)
console.log('Economy:', estimates.economy.maxFeePerGas)
console.log('Standard:', estimates.standard.maxFeePerGas)
console.log('Fast:', estimates.fast.maxFeePerGas)Every helper returns CalculatedFees with maxFeePerGas and maxPriorityFeePerGas as hex strings, ready to drop into a UserOperationV07.
calculateDynamicFees accepts a FeeConfig:
import type { FeeConfig } from '@embarkai/core'
const config: FeeConfig = {
baseFeeBuffer: 0.5, // +50% on top of the current base fee
priorityFeeBuffer: 0.05, // +5% on top of the bundler's suggested priority fee
fallbackMaxFeePerGas: 3_000_000_000n, // used when the RPC does not report a base fee
fallbackMaxPriorityFeePerGas: 3_000_000_000n,
}Built-in presets:
| Preset | baseFeeBuffer | priorityFeeBuffer | Fallback max fee / priority fee |
|---|---|---|---|
DEFAULT_FEE_CONFIG (standard) | 0.5 | 0.05 | 3 gwei / 3 gwei |
CONSERVATIVE_FEE_CONFIG | 1.0 | 0.25 | 5 gwei / 3 gwei |
calculateFastFees | 0.75 | 0.5 | 3 gwei / 2 gwei |
calculateEconomyFees | 0.27 | 0.01 | 1 gwei / 0.5 gwei |
Building with Dynamic Fees
createUserOperationWithDynamicFees assembles a UserOperationV07 from the params and fills in the fees for the chosen strategy:
import { createUserOperationWithDynamicFees, createFeeChainContext } from '@embarkai/core'
const context = createFeeChainContext(994873017)
const op = await createUserOperationWithDynamicFees(
{ sender: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e', nonce: '0x0', callData: '0x', feeType: 'fast' },
context,
)Packing
Convert a v0.7 UserOperation into the packed format required by the EntryPoint contract. The second argument tells the packer whether to include factory / factoryData as initCode:
import { packUserOperationV07 } from '@embarkai/core'
const packed = packUserOperationV07(userOp, false)
// packed: PackedUserOperationV07 with initCode, accountGasLimits, gasFees, paymasterAndDataRetryable Errors
import { isNetworkError } from '@embarkai/core'
try {
// ...
} catch (error) {
if (isNetworkError(String(error))) {
// safe to retry
}
}Usage with ServerWallet
In most cases ServerWallet handles all bundler interactions internally. sendUserOperation estimates gas, applies the chosen fee strategy, signs with MPC and submits to the bundler of the manager’s chain:
import { createServerWalletManager, MemoryKeyshareStorage } from '@embarkai/core'
const manager = createServerWalletManager({
apiKey: process.env.EMBARK_API_KEY!,
chainId: 994873017,
storage: new MemoryKeyshareStorage(),
})
const wallet = await manager.getWallet('treasury')
const userOpHash = await wallet.sendUserOperation(
'0x742d35Cc6634C0532925a3b844Bc454e4438f44e',
'1000000000000000000',
'0x',
'fast', // 'economy' | 'standard' | 'fast'
)
const receipt = await wallet.waitForUserOperationReceipt(userOpHash, {
pollIntervalMs: 2000,
maxPollTimeMs: 60000,
})
console.log(receipt.success, receipt.transactionHash)Next Steps
- Server Wallets — high-level wallet API that wraps the bundler
- Contract Encoding — encode calldata for token transfers and contract calls
- Vault Backup — back up keyshares for disaster recovery