본문으로 건너뛰기

Bundler RPC

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

UserOperation mempool 흐름

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 추정최종 전송설명
senderaddress필수필수미배포 반사실적 주소를 포함한 스마트 계정
nonceQUANTITY필수필수EntryPoint가 관리하는 nonce
factory / factoryDataaddress / DATA조건부조건부최초 배포용 Factory와 calldata
callDataDATA필수필수계정으로 보낼 비즈니스 실행 데이터
callGasLimitQUANTITY생략 또는 임시값 가능필수실행 Gas 한도
verificationGasLimitQUANTITY생략 또는 임시값 가능필수Factory 및 검증 Gas 한도
preVerificationGasQUANTITY생략 또는 임시값 가능필수calldata와 bundle 고정 오버헤드
maxFeePerGas / maxPriorityFeePerGasQUANTITY선택필수최대 총 Gas 단가 및 우선 수수료
paymasteraddress조건부조건부Gas를 지급하는 Paymaster
paymasterVerificationGasLimit / paymasterPostOpGasLimitQUANTITY조건부조건부Paymaster 검증 및 postOp 한도
paymasterDataDATA선택선택사용자 정의 권한 또는 정책 데이터
signatureDATA더미 가능실제 권한 필수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 필드 결정​

계정 상태factoryfactoryData
미배포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 필드가 남음
AA21deposit 또는 prefund 부족
AA24서명, hash, chainId, EntryPoint 불일치
AA25nonce 사용됨 또는 정책 위반
AA31 / AA33Paymaster deposit 또는 검증 실패
receipt가 오래 없음mempool, Gas 견적, nonce, Relayer
success: falseUserOperationRevertReason 및 비즈니스 호출