Bundler RPC
BOT Chain Bundler는 RPC UserOperation을 수신해 시뮬레이션하고 대기열을 관리하며, Relayer가 일반 트랜잭션으로 EntryPoint를 호출하도록 패키징합니다. 일반 BOT Chain RPC는 eth_sendUserOperation을 제공하지 않습니다.

1. RPC UserOperation v0.7
BOT Chain은 EntryPoint v0.7을 사용하며 v0.6 필드 형식과 호환되지 않습니다.
1.1 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 | 필수 | 필수 | 미배포 반사실적 주소를 포함한 스마트 계정 |
nonce | QUANTITY | 필수 | 필수 | EntryPoint가 관리하는 nonce |
factory / factoryData | address / DATA | 조건부 | 조건부 | 최초 배포용 Factory와 calldata |
callData | DATA | 필수 | 필수 | 계정으로 보낼 비즈니스 실행 데이터 |
callGasLimit | QUANTITY | 생략 또는 임시값 가능 | 필수 | 실행 Gas 한도 |
verificationGasLimit | QUANTITY | 생략 또는 임시값 가능 | 필수 | Factory 및 검증 Gas 한도 |
preVerificationGas | QUANTITY | 생략 또는 임시값 가능 | 필수 | calldata와 bundle 고정 오버헤드 |
maxFeePerGas / maxPriorityFeePerGas | QUANTITY | 선택 | 필수 | 최대 총 Gas 단가 및 우선 수수료 |
paymaster | address | 조건부 | 조건부 | Gas를 지급하는 Paymaster |
paymasterVerificationGasLimit / paymasterPostOpGasLimit | QUANTITY | 조건부 | 조건부 | Paymaster 검증 및 postOp 한도 |
paymasterData | DATA | 선택 | 선택 | 사용자 정의 권한 또는 정책 데이터 |
signature | DATA | 더미 가능 | 실제 권한 필수 | sender가 해석하고 검증 |
QUANTITY는 불필요한 선행 0이 없는 0x 접두 16진수 정수(0은 0x0), DATA는 짝수 길이 0x 접두 bytes(빈 값은 0x)입니다.
2. UserOperation 구성
다음 TypeScript 코드는 순서대로 결합해 실행할 수 있습니다. BOT Chain Testnet, SimpleAccount, Paymaster 미사용 예시입니다.
npm install viem
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 message = payload.error
? `${payload.error.code}: ${payload.error.message}`
: `HTTP ${response.status}`;
throw new Error(message);
}
if (payload.result === undefined) {
throw new Error(`Bundler returned no result for ${method}`);
}
return payload.result;
}
환경 변수 개인키는 서버 또는 로컬 테스트에만 사용합니다. 브라우저에서는 지갑이 제공하는 서명 계정을 사용하고 개인키를 코드에 포함하지 마세요.
2.1 sender 결정
sender는 Owner EOA가 아니라 스마트 계정입니다. SimpleAccount는 getAddress(owner, salt)로 계산하며 eth_getCode로 배포 여부를 확인합니다.
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 인코딩된 생성 calldata 제공 |
| 배포됨 | 생략 | 생략 |
배포된 계정에 Factory 필드를 포함하면 AA10 sender already constructed가 발생합니다.
const factoryFields = accountDeployed ? {} : {
factory: FACTORY,
factoryData: encodeFunctionData({
abi: factoryAbi, functionName: "createAccount", args: [owner.address, salt],
}),
};
2.3 nonce 읽기
EntryPoint.getNonce(sender, key)를 사용합니다. 상위 192 bit는 key, 하위 64 bit는 sequence입니다. SimpleAccount 기본 key는 0입니다. EOA nonce, eth_getTransactionCount, 동기화되지 않은 로컬 카운터를 사용하지 마세요.
const nonce = await publicClient.readContract({
address: ENTRY_POINT, abi: entryPointAbi, functionName: "getNonce",
args: [sender, 0n],
});
2.4 callData 생성
callData는 계정 구현이 정의합니다. SimpleAccount는 execute 또는 executeBatch를 사용하며 validateUserOp 권한 정책을 준수해야 합니다.
// 실제 비즈니스 컨트랙트, 금액 및 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 미사용 시 관련 필드를 모두 생략하고 충분한 계정 deposit 또는 잔액을 준비합니다. 사용 시 Paymaster 주소, 두 Gas 한도 및 구현별 데이터를 제공합니다. 미사용 시 0 주소를 넣지 마세요. BOT Chain은 공개 Paymaster를 제공하지 않습니다.
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)에 전달하며 시뮬레이션과 추정에만 사용됩니다. sender, nonce, callData는 최종값을 쓰고 Factory와 Paymaster 필드는 조건에 따라 포함합니다. Gas 한도는 0 또는 임시값, signature는 적절한 형식과 길이의 더미를 사용할 수 있습니다.
SimpleAccount 65-byte 더미 서명(추정 전용):
0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c
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 추정값 반영
반환된 callGasLimit, verificationGasLimit, preVerificationGas, 두 Gas 가격과 Paymaster 사용 시 두 한도를 최종 객체에 반영합니다. 이후 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 계산
QUANTITY를 bigint로 변환합니다. EntryPoint 주소, 버전, chainId는 hash에 포함되고 signature는 포함되지 않습니다.
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,
});
2.9 최종 signature
SimpleAccount는 Owner의 EIP-191 ECDSA 서명이 필요합니다. 사용자 정의 계정은 multisig, Session Key, 집계 서명 등을 사용할 수 있습니다. 서명 후 hash 대상 필드를 변경하지 마세요.
const signature = await owner.signMessage({ message: { raw: userOpHash } });
const signedUserOperation = { ...finalUserOperation, signature };
2.10 전송 및 결과 조회
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((r) => setTimeout(r, 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 주소 배열을 반환합니다. 구성된 주소가 포함되고 Bundler의 eth_chainId가 체인 RPC와 일치하는지 확인합니다.
3.2 eth_estimateUserOperationGas
추정용 userOp, 지원 entryPoint, 선택적 stateOverrides를 받습니다. state override는 balance, nonce, code, state, stateDiff를 지원하며 현재 시뮬레이션에만 적용됩니다.
주요 반환값은 callGasLimit, verificationGasLimit(Skandha 별칭 verificationGas), preVerificationGas, 두 Gas 가격입니다. Paymaster 사용 시 검증 및 postOp 한도와 선택적 validAfter / validUntil도 반환합니다.
3.3 eth_sendUserOperation
최종 UserOperation과 EntryPoint를 받아 외부 handleOps 트랜잭션 hash가 아닌 bytes32 userOpHash를 반환합니다. Bundler 수락은 패키징 흐름 진입을 의미할 뿐 온체인 성공을 보장하지 않습니다.
3.4 eth_getUserOperationByHash
찾지 못하면 null, 찾으면 원본 userOperation, entryPoint, 선택적 blockNumber, blockHash, transactionHash를 반환합니다. mempool에 있으면 블록·트랜잭션 필드가 없을 수 있습니다.
3.5 eth_getUserOperationReceipt
온체인 포함 전에는 null입니다. userOpHash, sender, nonce, 선택적 paymaster, actualGasCost, actualGasUsed, success, 선택적 reason, logs, 외부 receipt를 반환합니다. 최종 비즈니스 결과는 success를 확인합니다.
3.6 skandha_getGasPrice
인자 없이 maxFeePerGas와 maxPriorityFeePerGas를 반환합니다. 다른 Bundler용 대체 전략도 두 필드를 생성해야 합니다. eth_gasPrice는 입력값일 뿐 완전한 EIP-1559 견적이 아닙니다. base fee가 0이어도 UserOperation Gas 가격을 0으로 설정하지 마세요.
4. 문제 해결
| 증상 | 확인 항목 |
|---|---|
Invalid Entrypoint | 지원 목록, 네트워크, EntryPoint 주소 |
| 파라미터 검증 실패 | QUANTITY / DATA, 주소 길이, 조건부 필드 |
AA10 | 배포된 계정에 Factory 필드가 남음 |
AA21 | deposit 또는 prefund 부족 |
AA24 | 서명, hash, chainId, EntryPoint 불일치 |
AA25 | nonce 사용됨 또는 정책 위반 |
AA31 / AA33 | Paymaster deposit 또는 검증 실패 |
| receipt가 오래 없음 | mempool, Gas 견적, nonce, Relayer |
success: false | UserOperationRevertReason 및 비즈니스 호출 |