Bundler RPC
BotChain Bundler 接收 RPC UserOperation、执行模拟、维护 UserOperation 队列,并由 Relayer 通过普通交易调用 EntryPoint。普通 BotChain 链 RPC 不提供 eth_sendUserOperation。

1. RPC UserOperation v0.7
BotChain 使用 EntryPoint v0.7,不兼容 v0.6 字段格式。
1.1 Bundler RPC 调用结构
{
"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 字段表
| 字段 | 类型 | Gas 估算 | 最终发送 | 说明 |
|---|---|---|---|---|
sender | address(20 字节、0x 前缀十六进制地址) | 必填 | 必填 | 智能账户地址,包括尚未部署的反事实地址 |
nonce | QUANTITY(无多余前导零的 0x 前缀十六进制整数;零为 0x0) | 必填 | 必填 | EntryPoint 管理的账户 nonce |
factory | address | 条件字段 | 条件字段 | 首次部署账户使用的 Factory |
factoryData | DATA(偶数长度的 0x 前缀十六进制 bytes;空值为 0x) | 条件字段 | 条件字段 | Factory 创建账户的调用数据 |
callData | DATA | 必填 | 必填 | 发送给智能账户的业务执行数据 |
callGasLimit | QUANTITY | 可省略或使用临时值 | 必填 | 账户业务执行 Gas 上限 |
verificationGasLimit | QUANTITY | 可省略或使用临时值 | 必填 | Factory 和账户验证 Gas 上限 |
preVerificationGas | QUANTITY | 可省略或使用临时值 | 必填 | calldata 与 bundle 固定开销 |
maxFeePerGas | QUANTITY | 可选 | 必填 | 最高总 Gas 单价 |
maxPriorityFeePerGas | QUANTITY | 可选 | 必填 | 最高优先费 |
paymaster | address | 条件字段 | 条件字段 | 支付 Gas 的 Paymaster |
paymasterVerificationGasLimit | QUANTITY | 条件字段 | 条件字段 | Paymaster 验证 Gas 上限 |
paymasterPostOpGasLimit | QUANTITY | 条件字段 | 条件字段 | Paymaster postOp Gas 上限 |
paymasterData | DATA | 可选 | 可选 | Paymaster 自定义授权或策略数据 |
signature | DATA | 必填,可用占位值 | 必填,真实授权 | 由 sender 账户解释和验证 |
2. UserOperation 构造
以下 TypeScript 片段可以按顺序拼接执行,示例使用 BotChain Testnet、SimpleAccount 和无 Paymaster 路径。先安装 viem:
npm install viem
公共配置和 RPC 辅助函数:
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;
}
示例通过环境变量读取 Owner 私钥,仅适合服务端或本地测试。浏览器应用应改用钱包提供的签名账户,不要把私钥写入前端代码。
2.1 确定 sender
sender 是智能账户地址,不是 Owner EOA。
-
SimpleAccount 使用
SimpleAccountFactory.getAddress(owner, salt)计算地址。 -
自定义账户按自己的 Factory 或部署规则确定地址。
-
通过链 RPC
eth_getCode判断sender是否已经部署。
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 决定 Factory 字段
| 账户状态 | factory | factoryData |
|---|---|---|
| 尚未部署 | 提供 Factory 地址 | 提供创建账户的 ABI 调用数据 |
| 已经部署 | 省略 | 省略 |
BotChain SimpleAccount 的 factoryData 对应 createAccount(owner, salt)。已部署账户继续携带 Factory 字段会触发 AA10 sender already constructed。
const factoryFields = accountDeployed
? {}
: {
factory: FACTORY,
factoryData: encodeFunctionData({
abi: factoryAbi,
functionName: "createAccount",
args: [owner.address, salt],
}),
};
2.3 读取 nonce
nonce 来自 EntryPoint 的 getNonce(sender, key):
高 192 位:nonce key
低 64 位:该 key 的 sequence
BotChain SimpleAccount 默认使用 key 0。不要使用 Owner EOA 的交易 nonce、智能账户的 eth_getTransactionCount 或未与 EntryPoint 同步的本地计数器。
const nonce = await publicClient.readContract({
address: ENTRY_POINT,
abi: entryPointAbi,
functionName: "getNonce",
args: [sender, 0n],
});
2.4 生成 callData
callData 由智能账户实现定义:
-
BotChain SimpleAccount 使用
execute或executeBatch。 -
自定义账户使用自己公开的执行接口。
-
callData必须符合账户在validateUserOp中实施的授权策略。
// 这里使用无金额、无 calldata 的调用演示编码过程。
// 实际接入时替换为业务合约地址、转账金额和业务 calldata。
const target = owner.address;
const targetValue = 0n;
const targetCallData = "0x";
const callData = encodeFunctionData({
abi: simpleAccountAbi,
functionName: "execute",
args: [target, targetValue, targetCallData],
});
2.5 决定 Gas 支付方
| 场景 | 字段与资金要求 |
|---|---|
| 不使用 Paymaster | 省略全部 Paymaster 字段,并确保账户 deposit 足够 |
| 使用 Paymaster | 提供 Paymaster 地址、两个 Gas 上限和实现要求的数据 |
未使用 Paymaster 时不要填写零地址。BotChain 没有公共 Paymaster,但允许开发者部署自己的实现。
以下示例不使用 Paymaster,因此不添加任何 Paymaster 字段,并在估算前检查 EntryPoint deposit 和账户原生币余额:
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 构造待估算 UserOperation
该对象作为 eth_estimateUserOperationGas(userOp, entryPoint) 的 userOp 参数,仅用于 Bundler 模拟和 Gas 估算,不会发送交易。
| 字段组 | 估算阶段 |
|---|---|
| sender、nonce、callData | 使用最终值 |
| factory、factoryData | 按账户部署状态提供或省略 |
| paymaster 字段 | 按赞助模式提供或省略 |
| 三个 Gas 上限 | 可省略、使用零值或临时值 |
| Gas 价格 | 可选;Bundler 会返回建议值 |
| signature | 使用格式和长度适合账户验证的占位值 |
当前 Skandha 接受空 DATA 0x 并在估算时替换为内部占位值。需要显式提供与真实签名相同长度时,BotChain SimpleAccount 可使用以下 65 字节占位签名:
0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c
占位签名只用于 Gas 估算,不能用于最终发送。
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 写回 Gas 估算
eth_estimateUserOperationGas 返回后,将以下值写入最终 UserOperation:
-
callGasLimit -
verificationGasLimit -
preVerificationGas -
maxFeePerGas -
maxPriorityFeePerGas -
使用 Paymaster 时的两个 Paymaster Gas 上限
写回后,除 signature 外的最终字段应保持不变。
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 计算 userOpHash
传入的是 Bundler RPC 形态的未打包 UserOperation,但 QUANTITY 字段需要先转换为 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,
});
该函数是纯链下计算。entryPointAddress、entryPointVersion 和 chainId 都参与最终哈希,必须与目标网络一致。signature 不参与哈希,因此此时仍可保留占位签名。
2.9 获取最终 signature
通过账户钱包、SDK 或签名服务对 userOpHash 生成最终 signature。
-
BotChain SimpleAccount 要求 Owner 对 UserOperation hash 进行 EIP-191 ECDSA 签名。
-
自定义账户可以使用多签、Session Key、聚合签名或其他验证方案。
真实签名必须与 sender.validateUserOp 的实现一致。签名后不得修改任何参与 hash 的字段。
const signature = await owner.signMessage({
message: { raw: userOpHash },
});
const signedUserOperation = {
...finalUserOperation,
signature,
};
2.10 发送并查询结果
最终 RPC UserOperation 交给 eth_sendUserOperation。Bundler 返回 userOpHash,应用再通过查询接口跟踪打包交易和执行 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 方法
3.1 eth_supportedEntryPoints
返回 Bundler 接受的 EntryPoint 地址数组。结果应包含部署信息中配置的 EntryPoint。应用还应确认 Bundler eth_chainId 与链 RPC 一致。
3.2 eth_estimateUserOperationGas
3.2.1 参数
| 位置 | 参数 | 类型 | 说明 |
|---|---|---|---|
| 1 | userOp | Estimate UserOperation | 待模拟的 RPC UserOperation |
| 2 | entryPoint | address | Bundler 支持的 EntryPoint |
| 3 | stateOverrides | object,可选 | 仅用于模拟的账户状态覆盖 |
状态覆盖按地址索引,支持 balance、nonce、code、state 和 stateDiff,只影响本次模拟。
3.2.2 返回
| 字段 | 类型 | 说明 |
|---|---|---|
callGasLimit | QUANTITY | 账户业务执行 Gas 上限 |
verificationGasLimit | QUANTITY | Factory 和账户验证 Gas 上限 |
verificationGas | QUANTITY | Skandha 返回的验证 Gas 别名 |
preVerificationGas | QUANTITY | calldata 与 bundle 固定开销 |
maxFeePerGas | QUANTITY | 建议最高总 Gas 单价 |
maxPriorityFeePerGas | QUANTITY | 建议最高优先费 |
paymasterVerificationGasLimit | QUANTITY,可选 | Paymaster 验证 Gas 上限 |
paymasterPostOpGasLimit | QUANTITY,可选 | Paymaster postOp Gas 上限 |
validAfter | QUANTITY,可选 | 验证有效期开始时间 |
validUntil | QUANTITY,可选 | 验证有效期结束时间 |
3.3 eth_sendUserOperation
参数为最终 RPC UserOperation 和 EntryPoint 地址。返回 bytes32 userOpHash,不是外层 handleOps 交易 hash。
Bundler 接受 UserOperation 只表示进入待打包流程,不表示已经上链或执行成功。
3.4 eth_getUserOperationByHash
查不到时返回 null,否则返回:
| 字段 | 说明 |
|---|---|
userOperation | 原始 RPC UserOperation |
entryPoint | 执行入口 |
blockNumber | 已打包区块号,可选 |
blockHash | 已打包区块 hash,可选 |
transactionHash | 外层 handleOps 交易 hash,可选 |
UserOperation 仍在 mempool 时,可能没有交易和区块字段。
3.5 eth_getUserOperationReceipt
未上链时返回 null。上链后的主要字段为:
| 字段 | 说明 |
|---|---|
userOpHash | UserOperation hash |
sender | 智能账户 |
nonce | UserOperation nonce |
paymaster | Gas 支付 Paymaster,可选 |
actualGasCost | 实际 Gas 成本 |
actualGasUsed | 实际 Gas 用量 |
success | 账户业务执行是否成功 |
reason | 失败原因,可选 |
logs | UserOperation 相关日志 |
receipt | 外层 handleOps 交易回执 |
最终业务结果以 success 为准。外层交易成功不代表其中每个 UserOperation 都成功。
3.6 skandha_getGasPrice
该扩展方法不需要参数,返回 maxFeePerGas 和 maxPriorityFeePerGas。若需兼容其他 Bundler,回退策略仍必须生成这两个字段;链 RPC 的 eth_gasPrice 只能作为定价输入,不能被误认为完整的 EIP-1559 双字段报价。即使链上 base fee 为零,也不要将 UserOperation Gas 价格设置为零。
4. 常见问题
| 现象 | 检查项 |
|---|---|
Invalid Entrypoint | Bundler 支持列表、网络和 EntryPoint 地址 |
| 参数校验失败 | QUANTITY、DATA、地址长度和条件字段 |
AA10 | 已部署账户仍携带 Factory 字段 |
AA21 | 账户 deposit 或 prefund 不足 |
AA24 | 账户签名、hash、chainId 或 EntryPoint 不一致 |
AA25 | nonce 已使用或不符合账户策略 |
AA31 / AA33 | Paymaster deposit 或验证失败 |
| 长期没有 receipt | mempool 状态、Gas 报价、nonce 和 Relayer 状态 |
receipt success: false | 解码 UserOperationRevertReason 并检查业务调用 |