SimpleAccount Reference Client
SimpleAccount Reference Client
This is a copyable Testnet reference for the standard SimpleAccount ABI:
factory.getAddress(owner, salt)
factory.createAccount(owner, salt)
account.execute(target, value, data)It runs the complete MegaFuel path for EntryPoint v0.6, v0.7, or v0.8:
derive sender → stub paymaster data → estimate → final paymaster data
→ EntryPoint getUserOpHash → account signature → submit → receipt + lifecycle statusIt is a reference client, not a universal account SDK. A different factory ABI,
account-call ABI, or validateUserOp signature scheme must be adapted and proven
on Testnet before production use.
Prerequisites
- Node.js 18 or newer.
- A Testnet MegaFuel public policy that is active, funded, and has
enable4337: true. - Rules that allow the derived smart-account sender and the exact inner
TARGET/method/recipient action. - A standard SimpleAccount factory for the selected EntryPoint version.
- A test owner private key. Keep it in a local environment variable only; never
paste it into an issue, chat, browser console, or policy request.
Run it
Create a clean directory, install the only dependency, and save the reference
source shown below as simpleaccount-reference-client.mjs.
mkdir megafuel-4337-reference && cd megafuel-4337-reference
npm init -y
npm install viem
# Use an ordinary BSC Testnet RPC for readContract/getCode calls.
export RPC_URL='https://<YOUR_BSC_TESTNET_RPC>'
export ENTRYPOINT_VERSION='0.8' # use 0.6, 0.7, or 0.8
export FACTORY='0x<STANDARD_SIMPLEACCOUNT_FACTORY>'
export OWNER_PRIVATE_KEY='0x<TEST_OWNER_PRIVATE_KEY>'
export TARGET='0x<WHITELISTED_INNER_TARGET>'
export TARGET_CALLDATA='0x<WHITELISTED_INNER_CALLDATA>'
export VALUE_WEI='0'
# Choose only the mode that the deployed account's validateUserOp verifies.
export SIGNATURE_MODE='raw' # or eip191
node simpleaccount-reference-client.mjsFor a v0.6 run, set ENTRYPOINT_VERSION=0.6 and use a factory compatible with
the v0.6 account. The script creates classic initCode and
paymasterAndData for v0.6; it creates the packed-family JSON fields for v0.7
and v0.8. Do not manually convert one wire format into the other.
The defaults are MegaFuel Testnet endpoints and chain ID 97. To run the same
client on Mainnet, explicitly set CHAIN_ID=56, PAYMASTER_URL, and
BUNDLER_URL to the Mainnet endpoints after completing Testnet acceptance.
Expected output and acceptance
The first output includes sender, whether the account was already deployed,
and the accepted userOpHash. That hash is not a transaction hash. The client
then prints receipt and lifecycle results every three seconds.
An accepted submission is complete only when either:
- receipt is non-null and
successistrue; or nr_getUserOperationStatusreports a terminalrejected,expired, or
droppedstate with a reason.
For an included receipt, perform the policy-accounting check in
Operating limits and policy funding: compare
actualGasCost with the policy's sponsoredGasfee and remainingBalance delta.
Signature choice
The reference can sign the EntryPoint-computed hash as either a raw secp256k1
signature (raw) or EIP-191 message signature (eip191). Neither mode is
inherently “the v0.6 mode” or “the v0.8 mode”; validateUserOp decides. The
MegaFuel reference MiniAccount fixture uses raw, while the MegaFuel reference
SimpleAccount setup for v0.6/v0.7 uses eip191. A third-party account must use
the scheme its own contract verifies.
Reference client source
Copy the complete code block from the published version of this page into
simpleaccount-reference-client.mjs. The reviewable source is maintained beside
this document as examples/simpleaccount-reference-client.mjs; the publication
process expands it inline so the public page remains self-contained.
// MegaFuel ERC-4337 SimpleAccount reference client.
//
// Install: npm install viem
// Run: ENTRYPOINT_VERSION=0.8 RPC_URL=... FACTORY=... OWNER_PRIVATE_KEY=... \
// TARGET=... TARGET_CALLDATA=... node simpleaccount-reference-client.mjs
//
// This is a reference implementation for the standard SimpleAccount ABI only:
// factory.getAddress(address,uint256)
// factory.createAccount(address,uint256)
// account.execute(address,uint256,bytes)
// A wallet with another account ABI or signature scheme must adapt the relevant functions.
import {
concatHex,
createPublicClient,
getAddress,
hexToBigInt,
isAddress,
toHex,
zeroHash,
} from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
const entryPoints = {
'0.6': '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789',
'0.7': '0x0000000071727De22E5E9d8BAf0edAc6f37da032',
'0.8': '0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108',
}
const factoryAbi = [
{
type: 'function', name: 'getAddress', stateMutability: 'view',
inputs: [{ type: 'address' }, { type: 'uint256' }], outputs: [{ type: 'address' }],
},
{
type: 'function', name: 'createAccount', stateMutability: 'nonpayable',
inputs: [{ type: 'address' }, { type: 'uint256' }], outputs: [{ type: 'address' }],
},
]
const accountAbi = [
{
type: 'function', name: 'execute', stateMutability: 'nonpayable',
inputs: [{ type: 'address' }, { type: 'uint256' }, { type: 'bytes' }], outputs: [],
},
]
const packedEntryPointAbi = [
{
type: 'function', name: 'getUserOpHash', stateMutability: 'view',
inputs: [{
name: 'userOp', type: 'tuple', components: [
{ name: 'sender', type: 'address' },
{ name: 'nonce', type: 'uint256' },
{ name: 'initCode', type: 'bytes' },
{ name: 'callData', type: 'bytes' },
{ name: 'accountGasLimits', type: 'bytes32' },
{ name: 'preVerificationGas', type: 'uint256' },
{ name: 'gasFees', type: 'bytes32' },
{ name: 'paymasterAndData', type: 'bytes' },
{ name: 'signature', type: 'bytes' },
],
}], outputs: [{ type: 'bytes32' }],
},
]
const unpackedEntryPointAbi = [
{
type: 'function', name: 'getUserOpHash', stateMutability: 'view',
inputs: [{
name: 'userOp', type: 'tuple', components: [
{ name: 'sender', type: 'address' },
{ name: 'nonce', type: 'uint256' },
{ name: 'initCode', type: 'bytes' },
{ name: 'callData', type: 'bytes' },
{ name: 'callGasLimit', type: 'uint256' },
{ name: 'verificationGasLimit', type: 'uint256' },
{ name: 'preVerificationGas', type: 'uint256' },
{ name: 'maxFeePerGas', type: 'uint256' },
{ name: 'maxPriorityFeePerGas', type: 'uint256' },
{ name: 'paymasterAndData', type: 'bytes' },
{ name: 'signature', type: 'bytes' },
],
}], outputs: [{ type: 'bytes32' }],
},
]
function required(name) {
const value = process.env[name]
if (!value) throw new Error(`missing ${name}`)
return value
}
function address(name) {
const value = required(name)
if (!isAddress(value)) throw new Error(`${name} must be an address`)
return getAddress(value)
}
function hex(name) {
const value = required(name)
if (!/^0x[0-9a-fA-F]*$/.test(value)) throw new Error(`${name} must be 0x-prefixed hex`)
return value
}
function quantity(value) {
return toHex(typeof value === 'bigint' ? value : BigInt(value))
}
function uint128(value, label) {
const v = BigInt(value)
if (v < 0n || v >= (1n << 128n)) throw new Error(`${label} does not fit uint128`)
return toHex(v, { size: 16 })
}
function packTwo(high, low, highLabel, lowLabel) {
return concatHex([uint128(high, highLabel), uint128(low, lowLabel)])
}
async function rpc(url, method, params) {
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
})
if (!response.ok) throw new Error(`${method}: HTTP ${response.status}`)
const body = await response.json()
if (body.error) throw new Error(`${method}: ${body.error.message}`)
return body.result
}
function packedPaymasterAndData(paymaster, result) {
return concatHex([
getAddress(paymaster),
uint128(hexToBigInt(result.paymasterVerificationGasLimit), 'paymasterVerificationGasLimit'),
uint128(hexToBigInt(result.paymasterPostOpGasLimit), 'paymasterPostOpGasLimit'),
result.paymasterData,
])
}
function unpackedPaymasterAndData(paymaster, result) {
return concatHex([getAddress(paymaster), result.paymasterData])
}
async function main() {
const version = process.env.ENTRYPOINT_VERSION || '0.8'
const entryPoint = entryPoints[version]
if (!entryPoint) throw new Error('ENTRYPOINT_VERSION must be 0.6, 0.7, or 0.8')
const chainId = BigInt(process.env.CHAIN_ID || '97')
const rpcUrl = required('RPC_URL')
const paymasterUrl = process.env.PAYMASTER_URL || 'https://bsc-megafuel-testnet.nodereal.io/4337/paymaster'
const bundlerUrl = process.env.BUNDLER_URL || 'https://bsc-megafuel-testnet.nodereal.io/4337/bundler'
const factory = address('FACTORY')
const target = address('TARGET')
const targetCallData = hex('TARGET_CALLDATA')
const value = BigInt(process.env.VALUE_WEI || '0')
const salt = BigInt(process.env.SALT || '0')
const signatureMode = process.env.SIGNATURE_MODE || 'raw'
if (!['raw', 'eip191'].includes(signatureMode)) throw new Error('SIGNATURE_MODE must be raw or eip191')
const owner = privateKeyToAccount(hex('OWNER_PRIVATE_KEY'))
const client = createPublicClient({ transport: (await import('viem')).http(rpcUrl) })
const sender = await client.readContract({ address: factory, abi: factoryAbi, functionName: 'getAddress', args: [owner.address, salt] })
const deployed = Boolean(await client.getBytecode({ address: sender }))
const factoryData = deployed ? undefined : (await import('viem')).encodeFunctionData({ abi: factoryAbi, functionName: 'createAccount', args: [owner.address, salt] })
const callData = (await import('viem')).encodeFunctionData({ abi: accountAbi, functionName: 'execute', args: [target, value, targetCallData] })
const nonce = await client.readContract({ address: entryPoint, abi: [{ type: 'function', name: 'getNonce', stateMutability: 'view', inputs: [{ type: 'address' }, { type: 'uint192' }], outputs: [{ type: 'uint256' }] }], functionName: 'getNonce', args: [sender, 0n] })
const maxFeePerGas = BigInt(process.env.MAX_FEE_PER_GAS || '1000000000')
const maxPriorityFeePerGas = BigInt(process.env.MAX_PRIORITY_FEE_PER_GAS || '100000000')
if (maxPriorityFeePerGas > maxFeePerGas) throw new Error('MAX_PRIORITY_FEE_PER_GAS cannot exceed MAX_FEE_PER_GAS')
const sign = async (hash) => signatureMode === 'raw'
? owner.sign({ hash })
: owner.signMessage({ message: { raw: hash } })
const basic = { sender, nonce: quantity(nonce), callData, maxFeePerGas: quantity(maxFeePerGas), maxPriorityFeePerGas: quantity(maxPriorityFeePerGas), signature: await sign(zeroHash) }
const first = version === '0.6'
? { ...basic, initCode: factoryData ? concatHex([factory, factoryData]) : '0x' }
: { ...basic, ...(factoryData ? { factory, factoryData } : {}) }
const stub = await rpc(paymasterUrl, 'pm_getPaymasterStubData', [first, entryPoint, quantity(chainId), {}])
const estimateInput = first.version === '0.6' ? first : first
const withStub = version === '0.6'
? { ...estimateInput, paymasterAndData: unpackedPaymasterAndData(stub.paymaster, stub) }
: { ...estimateInput, paymaster: stub.paymaster, paymasterData: stub.paymasterData, paymasterVerificationGasLimit: stub.paymasterVerificationGasLimit, paymasterPostOpGasLimit: stub.paymasterPostOpGasLimit }
const estimate = await rpc(bundlerUrl, 'eth_estimateUserOperationGas', [withStub, entryPoint])
const estimated = { ...withStub, callGasLimit: estimate.callGasLimit, verificationGasLimit: estimate.verificationGasLimit, preVerificationGas: estimate.preVerificationGas }
if (version !== '0.6') {
estimated.paymasterVerificationGasLimit = estimate.paymasterVerificationGasLimit || stub.paymasterVerificationGasLimit
estimated.paymasterPostOpGasLimit = estimate.paymasterPostOpGasLimit || stub.paymasterPostOpGasLimit
}
const finalData = await rpc(paymasterUrl, 'pm_getPaymasterData', [estimated, entryPoint, quantity(chainId), {}])
const finalOp = version === '0.6'
? { ...estimated, paymasterAndData: unpackedPaymasterAndData(finalData.paymaster, finalData) }
: { ...estimated, paymaster: finalData.paymaster, paymasterData: finalData.paymasterData, paymasterVerificationGasLimit: finalData.paymasterVerificationGasLimit, paymasterPostOpGasLimit: finalData.paymasterPostOpGasLimit }
let userOpHash
if (version === '0.6') {
userOpHash = await client.readContract({
address: entryPoint, abi: unpackedEntryPointAbi, functionName: 'getUserOpHash',
args: [{ sender, nonce, initCode: finalOp.initCode, callData, callGasLimit: hexToBigInt(finalOp.callGasLimit), verificationGasLimit: hexToBigInt(finalOp.verificationGasLimit), preVerificationGas: hexToBigInt(finalOp.preVerificationGas), maxFeePerGas, maxPriorityFeePerGas, paymasterAndData: finalOp.paymasterAndData, signature: finalOp.signature }],
})
} else {
userOpHash = await client.readContract({
address: entryPoint, abi: packedEntryPointAbi, functionName: 'getUserOpHash',
args: [{ sender, nonce, initCode: factoryData ? concatHex([factory, factoryData]) : '0x', callData, accountGasLimits: packTwo(hexToBigInt(finalOp.verificationGasLimit), hexToBigInt(finalOp.callGasLimit), 'verificationGasLimit', 'callGasLimit'), preVerificationGas: hexToBigInt(finalOp.preVerificationGas), gasFees: packTwo(maxPriorityFeePerGas, maxFeePerGas, 'maxPriorityFeePerGas', 'maxFeePerGas'), paymasterAndData: packedPaymasterAndData(finalData.paymaster, finalData), signature: finalOp.signature }],
})
}
finalOp.signature = await sign(userOpHash)
const userOpHashReturned = await rpc(bundlerUrl, 'eth_sendUserOperation', [finalOp, entryPoint])
console.log(JSON.stringify({ version, sender, deployed, userOpHash: userOpHashReturned }, null, 2))
for (let attempt = 0; attempt < 40; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 3000))
const [receipt, status] = await Promise.all([
rpc(bundlerUrl, 'eth_getUserOperationReceipt', [userOpHashReturned]),
rpc(bundlerUrl, 'nr_getUserOperationStatus', [userOpHashReturned]),
])
console.log(JSON.stringify({ attempt: attempt + 1, status, receipt }, null, 2))
if (receipt || ['rejected', 'expired', 'dropped'].includes(status?.status)) break
}
}
main().catch((error) => { console.error(error.message); process.exitCode = 1 })
Updated about 4 hours ago

