Skip to main content

Bundler RPC

The BOT Chain Bundler accepts RPC UserOperations, runs simulations, maintains the UserOperation queue, and uses a Relayer to call EntryPoint through a standard transaction. The standard BOT Chain RPC does not provide eth_sendUserOperation.

UserOperation mempool flow

1. RPC UserOperation v0.7​

BOT Chain uses EntryPoint v0.7 and is not compatible with the v0.6 field format.

1.1 Bundler RPC request structure​

{
"sender": "0x...",
"nonce": "0x...",
"factory": "0x...",
"factoryData": "0x...",
"callData": "0x...",
"callGasLimit": "0x...",
"verificationGasLimit": "0x...",
"preVerificationGas": "0x...",
"maxFeePerGas": "0x...",
"maxPriorityFeePerGas": "0x...",
"paymaster": "0x...",
"paymasterVerificationGasLimit": "0x...",
"paymasterPostOpGasLimit": "0x...",
"paymasterData": "0x...",
"signature": "0x..."
}

1.2 Fields​

FieldTypeGas estimationFinal submissionDescription
senderaddress (20-byte, 0x-prefixed hexadecimal address)RequiredRequiredSmart-account address, including a counterfactual address that has not been deployed
nonceQUANTITY (0x-prefixed hexadecimal integer without redundant leading zeros; zero is 0x0)RequiredRequiredAccount nonce managed by EntryPoint
factoryaddressConditionalConditionalFactory used for the account's first deployment
factoryDataDATA (even-length, 0x-prefixed hexadecimal bytes; empty is 0x)ConditionalConditionalCalldata for the Factory to create the account
callDataDATARequiredRequiredBusiness execution data sent to the smart account
callGasLimitQUANTITYMay be omitted or temporaryRequiredGas limit for account execution
verificationGasLimitQUANTITYMay be omitted or temporaryRequiredGas limit for Factory and account validation
preVerificationGasQUANTITYMay be omitted or temporaryRequiredFixed calldata and bundle overhead
maxFeePerGasQUANTITYOptionalRequiredMaximum total Gas price
maxPriorityFeePerGasQUANTITYOptionalRequiredMaximum priority fee
paymasteraddressConditionalConditionalPaymaster that pays the Gas
paymasterVerificationGasLimitQUANTITYConditionalConditionalGas limit for Paymaster validation
paymasterPostOpGasLimitQUANTITYConditionalConditionalGas limit for Paymaster postOp
paymasterDataDATAOptionalOptionalCustom Paymaster authorization or policy data
signatureDATARequired; a placeholder may be usedRequired; real authorizationInterpreted and validated by the sender account

2. Construct a UserOperation​

The following TypeScript snippets can be combined and run in order. The example uses BOT Chain Testnet, SimpleAccount, and no Paymaster. Install viem first:

npm install viem

Shared configuration and the RPC helper:

import {
createPublicClient,
encodeFunctionData,
http,
parseAbi,
toHex,
type Hex,
} from "viem";
import { getUserOperationHash } from "viem/account-abstraction";
import { privateKeyToAccount } from "viem/accounts";

const CHAIN_ID = 968;
const RPC_URL = "https://rpc.bohr.life";
const BUNDLER_URL = "https://bundler.bohr.life/rpc/";
const ENTRY_POINT = "0x0000000071727De22E5E9d8BAf0edAc6f37da032";
const FACTORY = "0xBC88d6012b3bf8426C2851d3798cEB5257658332";

const ownerPrivateKey = process.env.OWNER_PRIVATE_KEY;
if (!ownerPrivateKey) throw new Error("OWNER_PRIVATE_KEY is required");

const owner = privateKeyToAccount(ownerPrivateKey as Hex);
const publicClient = createPublicClient({ transport: http(RPC_URL) });

const factoryAbi = parseAbi([
"function getAddress(address owner, uint256 salt) view returns (address)",
"function createAccount(address owner, uint256 salt) returns (address)",
]);
const entryPointAbi = parseAbi([
"function getNonce(address sender, uint192 key) view returns (uint256)",
"function balanceOf(address account) view returns (uint256)",
]);
const simpleAccountAbi = parseAbi([
"function execute(address dest, uint256 value, bytes func)",
]);

async function bundlerRpc<T>(
method: string,
params: unknown[] = [],
): Promise<T> {
const response = await fetch(BUNDLER_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method,
params,
}),
});

const payload = (await response.json()) as {
result?: T;
error?: {
code: number;
message: string;
data?: unknown;
};
};

if (!response.ok || payload.error) {
const errorMessage = payload.error
? `${payload.error.code}: ${payload.error.message}`
: `HTTP ${response.status}`;

throw new Error(errorMessage);
}

if (payload.result === undefined) {
throw new Error(`Bundler returned no result for ${method}`);
}

return payload.result;
}

The example reads the Owner private key from an environment variable and is only suitable for server-side or local testing. Browser applications should use an account supplied by a wallet and must never embed private keys in frontend code.

2.1 Determine sender​

sender is the smart-account address, not the Owner EOA.

  • SimpleAccount derives it through SimpleAccountFactory.getAddress(owner, salt).
  • A custom account follows its own Factory or deployment rules.
  • Use the chain RPC method eth_getCode to determine whether sender has already been deployed.
const salt = 0n;

const sender = await publicClient.readContract({
address: FACTORY,
abi: factoryAbi,
functionName: "getAddress",
args: [owner.address, salt],
});

const accountCode = await publicClient.getCode({ address: sender });
const accountDeployed = accountCode !== undefined && accountCode !== "0x";

2.2 Set the Factory fields​

Account statefactoryfactoryData
Not deployedProvide the Factory addressProvide ABI-encoded account-creation calldata
Already deployedOmitOmit

For BOT Chain SimpleAccount, factoryData encodes createAccount(owner, salt). Supplying Factory fields for an already deployed account triggers AA10 sender already constructed.

const factoryFields = accountDeployed
? {}
: {
factory: FACTORY,
factoryData: encodeFunctionData({
abi: factoryAbi,
functionName: "createAccount",
args: [owner.address, salt],
}),
};

2.3 Read the nonce​

Read the nonce from EntryPoint through getNonce(sender, key):

High 192 bits: nonce key
Low 64 bits: sequence for that key

BOT Chain SimpleAccount uses key 0 by default. Do not substitute the Owner EOA transaction nonce, the smart account's eth_getTransactionCount, or a local counter that is not synchronized with EntryPoint.

const nonce = await publicClient.readContract({
address: ENTRY_POINT,
abi: entryPointAbi,
functionName: "getNonce",
args: [sender, 0n],
});

2.4 Generate callData​

The smart-account implementation defines callData:

  • BOT Chain SimpleAccount uses execute or executeBatch.
  • A custom account uses its own public execution interface.
  • callData must comply with the authorization policy enforced by the account in validateUserOp.
// This example uses a call with no value and empty calldata to demonstrate encoding.
// Replace these values with the business contract, transfer value, and calldata.
const target = owner.address;
const targetValue = 0n;
const targetCallData = "0x";

const callData = encodeFunctionData({
abi: simpleAccountAbi,
functionName: "execute",
args: [target, targetValue, targetCallData],
});

2.5 Choose the Gas payer​

ScenarioFields and funding requirements
No PaymasterOmit all Paymaster fields and ensure the account deposit is sufficient
With a PaymasterProvide the Paymaster address, both Paymaster Gas limits, and the implementation-specific data

Do not provide the zero address when a Paymaster is not used. BOT Chain does not provide a public Paymaster, but developers can deploy their own.

This example does not use a Paymaster. Before estimation, it checks the EntryPoint deposit and the account's native-token balance:

const [entryPointDeposit, senderBalance] = await Promise.all([
publicClient.readContract({
address: ENTRY_POINT,
abi: entryPointAbi,
functionName: "balanceOf",
args: [sender],
}),
publicClient.getBalance({ address: sender }),
]);

if (entryPointDeposit === 0n && senderBalance === 0n) {
throw new Error(
"Fund the sender or deposit funds for it in EntryPoint before estimating",
);
}

2.6 Construct the UserOperation for estimation​

This object is the userOp argument of eth_estimateUserOperationGas(userOp, entryPoint). It is used only for Bundler simulation and Gas estimation; it does not send a transaction.

Field groupEstimation stage
sender, nonce, callDataUse final values
factory, factoryDataProvide or omit according to deployment state
Paymaster fieldsProvide or omit according to the sponsorship mode
Three Gas limitsMay be omitted, zero, or temporary
Gas pricesOptional; the Bundler returns suggested values
signatureUse a placeholder with a format and length suitable for account validation

The current Skandha implementation accepts empty DATA (0x) and substitutes an internal placeholder during estimation. When an explicit placeholder with the same length as the final signature is required, BOT Chain SimpleAccount can use this 65-byte placeholder:

0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c

The placeholder is only for Gas estimation and cannot be used in the final submission.

const DUMMY_SIGNATURE =
"0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c";

const gasPrice = await bundlerRpc<{
maxFeePerGas: Hex;
maxPriorityFeePerGas: Hex;
}>("skandha_getGasPrice");

const estimateUserOperation = {
sender,
nonce: toHex(nonce),
...factoryFields,
callData,
callGasLimit: "0x0",
verificationGasLimit: "0x0",
preVerificationGas: "0x0",
maxFeePerGas: gasPrice.maxFeePerGas,
maxPriorityFeePerGas: gasPrice.maxPriorityFeePerGas,
signature: DUMMY_SIGNATURE,
};

2.7 Apply the Gas estimate​

After eth_estimateUserOperationGas returns, copy these values into the final UserOperation:

  • callGasLimit
  • verificationGasLimit
  • preVerificationGas
  • maxFeePerGas
  • maxPriorityFeePerGas
  • Both Paymaster Gas limits when using a Paymaster

After applying the estimate, all final fields except signature should remain unchanged.

type UserOperationGasEstimate = {
callGasLimit: Hex;
verificationGasLimit?: Hex;
verificationGas?: Hex;
preVerificationGas: Hex;
maxFeePerGas?: Hex;
maxPriorityFeePerGas?: Hex;
};

const gasEstimate = await bundlerRpc<UserOperationGasEstimate>(
"eth_estimateUserOperationGas",
[estimateUserOperation, ENTRY_POINT],
);

const verificationGasLimit =
gasEstimate.verificationGasLimit ?? gasEstimate.verificationGas;
if (!verificationGasLimit) {
throw new Error("Bundler did not return verificationGasLimit");
}

const finalUserOperation = {
...estimateUserOperation,
callGasLimit: gasEstimate.callGasLimit,
verificationGasLimit,
preVerificationGas: gasEstimate.preVerificationGas,
maxFeePerGas: gasEstimate.maxFeePerGas ?? gasPrice.maxFeePerGas,
maxPriorityFeePerGas:
gasEstimate.maxPriorityFeePerGas ?? gasPrice.maxPriorityFeePerGas,
};

2.8 Calculate userOpHash​

The function accepts the unpacked Bundler RPC form of a UserOperation, but QUANTITY fields must first be converted to bigint:

const sdkUserOperation = {
...finalUserOperation,
nonce: BigInt(finalUserOperation.nonce),
callGasLimit: BigInt(finalUserOperation.callGasLimit),
verificationGasLimit: BigInt(finalUserOperation.verificationGasLimit),
preVerificationGas: BigInt(finalUserOperation.preVerificationGas),
maxFeePerGas: BigInt(finalUserOperation.maxFeePerGas),
maxPriorityFeePerGas: BigInt(finalUserOperation.maxPriorityFeePerGas),
};

const userOpHash = getUserOperationHash({
userOperation: sdkUserOperation,
entryPointAddress: ENTRY_POINT,
entryPointVersion: "0.7",
chainId: CHAIN_ID,
});

This function performs a pure off-chain calculation. entryPointAddress, entryPointVersion, and chainId all contribute to the final hash and must match the target network. signature does not contribute to the hash, so the placeholder can remain at this stage.

2.9 Obtain the final signature​

Use an account wallet, SDK, or signing service to sign userOpHash and produce the final signature.

  • BOT Chain SimpleAccount requires the Owner to sign the UserOperation hash with EIP-191 ECDSA.
  • A custom account may use multisig, Session Keys, aggregated signatures, or another validation scheme.

The real signature must match the implementation of sender.validateUserOp. Do not change any hash-participating field after signing.

const signature = await owner.signMessage({
message: { raw: userOpHash },
});

const signedUserOperation = {
...finalUserOperation,
signature,
};

2.10 Submit and query the result​

Submit the final RPC UserOperation through eth_sendUserOperation. The Bundler returns a userOpHash. The application then polls the query methods to track the bundle transaction and execution receipt.

const submittedUserOpHash = await bundlerRpc<Hex>(
"eth_sendUserOperation",
[signedUserOperation, ENTRY_POINT],
);

if (submittedUserOpHash.toLowerCase() !== userOpHash.toLowerCase()) {
throw new Error("Bundler returned an unexpected userOpHash");
}

type UserOperationReceipt = {
success: boolean;
receipt: { transactionHash: Hex };
};

let userOperationReceipt: UserOperationReceipt | null = null;
for (let attempt = 0; attempt < 60 && !userOperationReceipt; attempt++) {
userOperationReceipt = await bundlerRpc<UserOperationReceipt | null>(
"eth_getUserOperationReceipt",
[submittedUserOpHash],
);
if (!userOperationReceipt) {
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
}

if (!userOperationReceipt) {
throw new Error("Timed out waiting for UserOperation receipt");
}

console.log({
userOpHash: submittedUserOpHash,
transactionHash: userOperationReceipt.receipt.transactionHash,
success: userOperationReceipt.success,
});

3. Bundler RPC methods​

3.1 eth_supportedEntryPoints​

Returns the EntryPoint addresses accepted by the Bundler. The result should include the configured EntryPoint deployment. Applications should also verify that the Bundler's eth_chainId matches the chain RPC.

3.2 eth_estimateUserOperationGas​

3.2.1 Parameters​

PositionParameterTypeDescription
1userOpEstimate UserOperationRPC UserOperation to simulate
2entryPointaddressEntryPoint supported by the Bundler
3stateOverridesobject, optionalAccount-state overrides used only for simulation

State overrides are keyed by address and support balance, nonce, code, state, and stateDiff. They affect only the current simulation.

3.2.2 Result​

FieldTypeDescription
callGasLimitQUANTITYGas limit for account execution
verificationGasLimitQUANTITYGas limit for Factory and account validation
verificationGasQUANTITYValidation Gas alias returned by Skandha
preVerificationGasQUANTITYFixed calldata and bundle overhead
maxFeePerGasQUANTITYSuggested maximum total Gas price
maxPriorityFeePerGasQUANTITYSuggested maximum priority fee
paymasterVerificationGasLimitQUANTITY, optionalGas limit for Paymaster validation
paymasterPostOpGasLimitQUANTITY, optionalGas limit for Paymaster postOp
validAfterQUANTITY, optionalStart of the validation time range
validUntilQUANTITY, optionalEnd of the validation time range

3.3 eth_sendUserOperation​

The parameters are the final RPC UserOperation and the EntryPoint address. The method returns a bytes32 userOpHash, not the outer handleOps transaction hash.

Bundler acceptance only means that the UserOperation entered the packaging flow; it does not mean that the operation has been included on-chain or executed successfully.

3.4 eth_getUserOperationByHash​

Returns null when no operation is found. Otherwise it returns:

FieldDescription
userOperationOriginal RPC UserOperation
entryPointExecution entry point
blockNumberIncluded block number, optional
blockHashIncluded block hash, optional
transactionHashOuter handleOps transaction hash, optional

A UserOperation that is still in the mempool may not have transaction or block fields.

3.5 eth_getUserOperationReceipt​

Returns null until the operation is included on-chain. The primary fields after inclusion are:

FieldDescription
userOpHashUserOperation hash
senderSmart account
nonceUserOperation nonce
paymasterGas-paying Paymaster, optional
actualGasCostActual Gas cost
actualGasUsedActual Gas used
successWhether account business execution succeeded
reasonFailure reason, optional
logsLogs related to the UserOperation
receiptOuter handleOps transaction receipt

Use success as the final business result. A successful outer transaction does not mean that every UserOperation inside it succeeded.

3.6 skandha_getGasPrice​

This extension method takes no parameters and returns maxFeePerGas and maxPriorityFeePerGas. To support other Bundlers, a fallback strategy must still produce both fields. The chain RPC's eth_gasPrice may be used as a pricing input, but it is not a complete EIP-1559 two-field quote. Do not set the UserOperation Gas price to zero even if the on-chain base fee is zero.

4. Troubleshooting​

SymptomWhat to check
Invalid EntrypointBundler-supported list, network, and EntryPoint address
Parameter validation failsQUANTITY and DATA encoding, address length, and conditional fields
AA10An already deployed account still includes Factory fields
AA21Account deposit or prefund is insufficient
AA24Account signature, hash, chainId, or EntryPoint does not match
AA25Nonce was already used or violates the account policy
AA31 / AA33Paymaster deposit is insufficient or validation failed
Receipt does not appearMempool state, Gas quote, nonce, and Relayer state
Receipt has success: falseDecode UserOperationRevertReason and inspect the business call