This guide explains how Biconomy clients can safely upgrade existing ERC-4337 Accounts v2 to the Nexus / Modular Execution Environment (MEE) stack by running a self-contained upgrade script.
The script acts as a mini bundler, allowing you to submit a single UserOperation directly to the EntryPoint, without relying on Biconomy’s hosted bundler infrastructure.
⚠️ Important
Biconomy will be shutting down bundler operations on February 28th.
The script is only required for the one-time upgrade transaction, so expected transaction volume is very low.
For broader architecture and Nexus concepts, refer to docs.biconomy.io.
Withdraw Paymaster Funds
We request that all clients withdraw their Paymaster "gas tank" funds before the 28th of February. The funds will remain availablea for withdrawal after that date, but the UI for enabling this functionality will be removed.
You can call the withdrawTo method with the owner wallet via the explorer. Example: https://basescan.org/address/0x00000f79b7faf42eebadba19acc07cd08af44789#writeContract%23F12
EPv6 Paymaster: 0x00000f79b7faf42eebadba19acc07cd08af44789
EPv7 Paymasters: 0x0000006087310897e0BFfcb3f0Ed3704f7146852 (Base and OP)0x00000072a5F551D6E80b2f6ad4fB256A27841Bbc (All other chains)
Sample Server
This is a template server achieving the capabilities of the "mini bundler". You can use it as reference to build your solution.
What This Script Does
At a high level, the script:
- Upgrades the account implementation to Nexus
- Initializes the Nexus & Modular Execution Environment
- Wraps everything into a single ERC-4337
UserOperation - Submits the operation directly to the
EntryPoint
All steps execute atomically.
Step 1 — Encode the Implementation Upgrade
The first step prepares calldata to upgrade the account’s implementation contract.
const updateImplementationCalldata = encodeFunctionData({
abi: [
{
name: 'updateImplementation',
type: 'function',
stateMutability: 'nonpayable',
inputs: [{ name: 'newImplementation', type: 'address' }],
outputs: [],
},
],
functionName: 'updateImplementation',
args: [versionConfig.implementationAddress],
});
This updates the account logic in-place, preserving:
- Account address
- Storage
- ERC-4337 compatibility
Step 2 — Prepare Nexus Initialization Data
Once the implementation is upgraded, the account must be initialized as a Nexus account with a default validator.
Encode Nexus initialization
const initData = encodeFunctionData({
abi: [
{
name: 'initNexusWithDefaultValidator',
type: 'function',
stateMutability: 'nonpayable',
inputs: [{ type: 'bytes', name: 'data' }],
outputs: [],
},
],
functionName: 'initNexusWithDefaultValidator',
args: [ownerAddress as `0x${string}`],
});
Wrap initialization with bootstrap data
const initDataWithBootstrap = encodeAbiParameters(
[
{ name: 'bootstrap', type: 'address' },
{ name: 'initData', type: 'bytes' },
],
[versionConfig.bootStrapAddress, initData]
);
Encode initializeAccount
const initializeNexusCalldata = encodeFunctionData({
abi: [
{
name: 'initializeAccount',
type: 'function',
stateMutability: 'nonpayable',
inputs: [{ type: 'bytes', name: 'data' }],
outputs: [],
},
],
functionName: 'initializeAccount',
args: [initDataWithBootstrap],
});
This activates the Modular Execution Environment (MEE).
Step 3 — Execute Both Actions Atomically
Both the implementation upgrade and Nexus initialization are executed by the account itself using executeBatch.
const executeCalldata = encodeFunctionData({
abi: [
{
name: 'executeBatch',
type: 'function',
stateMutability: 'nonpayable',
inputs: [
{ name: 'dest', type: 'address[]' },
{ name: 'value', type: 'uint256[]' },
{ name: 'func', type: 'bytes[]' },
],
outputs: [],
},
],
functionName: 'executeBatch',
args: [
[smartAccountAddress, smartAccountAddress],
[0n, 0n],
[updateImplementationCalldata, initializeNexusCalldata],
],
});
This guarantees:
- No partial upgrades
- No externally-owned account control
- A single atomic state transition
Step 4 — Construct the ERC-4337 UserOperation
Fetch the nonce from EntryPoint
const nonce = await readContract(walletClient, {
address: entryPointAddress,
abi: [
{
name: 'getNonce',
type: 'function',
stateMutability: 'view',
inputs: [
{ name: 'sender', type: 'address' },
{ name: 'key', type: 'uint192' },
],
outputs: [{ type: 'uint256' }],
},
],
functionName: 'getNonce',
args: [smartAccountAddress, 0n],
});
Build the UserOperation
const userOp: any = {
sender: smartAccountAddress,
nonce,
initCode: '0x',
callData: executeCalldata,
callGasLimit: 800_000n,
verificationGasLimit: 500_000n,
preVerificationGas: 100_000n,
maxFeePerGas: 20_000_000n,
maxPriorityFeePerGas: 1_000_000n,
paymasterAndData: '0x',
signature: '0x',
};
Step 5 — Sign the UserOperation
Your users can sign the user operation in a regular way - with their regular Biconomy account SDK. This operation serves just to enable them an upgrade path when the Bundler is shut down.
Compute the UserOperation hash
const userOpHash = await readContract(walletClient, {
address: entryPointAddress,
abi: [
{
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' }],
},
],
functionName: 'getUserOpHash',
args: [userOp],
});
Sign using the existing account signer
const biconomyAccount = useBiconomyStore.getState().biconomyAccount;
const signature: any =
await biconomyAccount?.signUserOperationHash(userOpHash);
const signatureWithModuleAddress =
biconomyAccount?.getSignatureWithModuleAddress(signature);
userOp.signature = signatureWithModuleAddress;
Step 6 — Submit the UserOperation (Mini Bundler Mode)
Instead of relying on a hosted bundler, the script directly calls handleOps on the EntryPoint.
const handleOpsCalldata = encodeFunctionData({
abi: [
{
name: 'handleOps',
type: 'function',
stateMutability: 'nonpayable',
inputs: [
{
name: 'ops',
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' },
],
},
{ name: 'beneficiary', type: 'address' },
],
outputs: [],
},
],
functionName: 'handleOps',
args: [[userOp], walletClient.account.address],
});
const hash = await walletClient.sendTransaction({
to: entryPointAddress,
data: handleOpsCalldata,
value: 0n,
type: 'eip1559',
gas: 1_000_000n,
});
This is why the script functions as a mini bundler:
- No external bundler
- Direct EntryPoint interaction
- Fully permissionless
Next Steps
After execution, the account:
- Remains at the same address
- Is fully upgraded to Nexus / MEE
- Can adopt future modules, validators, and execution policies
For full Nexus documentation and follow-up integrations, see:
👉 docs.biconomy.io