Skip to content

🔁 Atomic and Composable Batch Execute

This tutorial demonstrates how to execute a fully gas-abstracted, multi-step transaction on Base using one user signature. No bridging. No leftover tokens. Just one-click UX with gas paid in USDC and a guarantee that either all instructions succeed or none do.

Unlike regular batch execute, composable batch execution allows developers to use the output of one function call as the input for the next one. To learn more, read the Runtime Parameter Injection guide.

🧠 Why Should You Care?

  • No ETH required for gas — everything is paid in USDC.
  • Lower dropoff in multi-step flows — users only sign once.
  • Eliminates complexity — no frontend juggling approvals, swaps, and deposits.
  • Business win: better UX, fewer support issues, and higher conversion.
  • Full support: Works for all EOA users, including regular MetaMask, Rabby, Trust, etc...

🛠️ 2 Setup

Import all the required dependencies.

import {
  createMeeClient,
  toMultichainNexusAccount,
  UniswapSwapRouterAbi,
  getMeeScanLink,
  runtimeERC20BalanceOf,
  greaterThanOrEqualTo,
} from "@biconomy/abstractjs";
 
import { http, parseUnits } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base, optimism } from "viem/chains";
import { balanceNotZeroConstraint } from "../utils/balanceNotZero.util";

2.1 Create an Orchestrator

An orchestrator is a smart account owned by the user. All instructions are executed on top of this account.

const eoa = privateKeyToAccount(Bun.env.PRIVATE_KEY as `0x${string}`);
 
const orchestrator = await toMultichainNexusAccount({
  chains: [optimism, base],
  transports: [http(), http()],
  signer: eoa,
});

2.2 Connect to the Modular Execution Environment (MEE)

Gasless multichain orchestration is enabled by connecting to the Modular Execution Environment. This is a trustless, globally distributed network of Relayer nodes executing instructions on top of smart accounts.

const meeClient = await createMeeClient({ account: orchestrator });

📑 3 Declare Constants

These are the contract addresses we'll need for this tutorial.

const UNISWAP_ROUTER_BASE = "0x2626664c2603336E57B271c5C0b26F421741e481";
const USDC_BASE           = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const WETH_BASE           = "0x4200000000000000000000000000000000000006";
const MORPHO_RE7_POOL     = "0xA2Cac0023a4797b4729Db94783405189a4203AFc";
 
// Change to be whatever you want the input to be.
const inputAmount = parseUnits("10", 6);

🔨 4 Build Instructions

4.1 Approve Uniswap

This will encode an ERC-20 approve function which approves the Uniswap contract to spend USDC.

const approveUniswap = await orchestrator.buildComposable({
  type: "approve",
  data: {
    spender: UNISWAP_ROUTER_BASE,
    tokenAddress: USDC_BASE,
    chainId: base.id,
    amount: inputAmount,
  },
});

4.2 Swap USDC → WETH (Runtime Injection)

Using the .buildComposable helper, we are encoding a call to the exactInputSingle function on the Uniswap contract. This will swap USDC for USDT.

Note the use of runtimeERC20BalanceOf in the amountIn field of the call. This means that we're not predetermining the amount being swapped - we'll use whatever is available on the orchestrator account.

const swapUSDCtoWeth = await orchestrator.buildComposable({
  type: "default",
  data: {
    chainId: base.id,
    abi: UniswapSwapRouterAbi,
    to: UNISWAP_ROUTER_BASE,
    functionName: "exactInputSingle",
    args: [{
      tokenIn:  USDC_BASE,
      amountIn: runtimeERC20BalanceOf({
        tokenAddress: USDC_BASE,
        targetAddress: orchestrator.addressOn(base.id, true),
        constraints: [balanceNotZeroConstraint],
      }),
      tokenOut: WETH_BASE,
      recipient: orchestrator.addressOn(base.id, true),
      fee: 100,
      amountOutMinimum: 0n,
      sqrtPriceLimitX96: 0n,
    }],
  },
});

🧠 Runtime injection lets you defer the exact amount to use until execution time — crucial when the actual balance isn’t known upfront.

4.3 Approve Morpho

This instruction approves Morpho to spend WETH. Again, not the usage of runtimeERC20BalanceOf function. Since we don't know how much exactly we'll get from a swap on Uniswap due to slippage - we're working with runtime values.

Another thing to note is the constraints field. It defines the minimum amount of WETH on the account before the orchestration will proceed with the approve instruction.

Since the orchestrator account is just a passthoguh, we can assume that the balance is 0 before the swap happens - so we can use the built-in balanceNotZeroConstraint

const approveMorpho = await orchestrator.buildComposable({
  type: "approve",
  data: {
    spender: MORPHO_RE7_POOL,
    chainId: base.id,
    tokenAddress: WETH_BASE,
    amount: runtimeERC20BalanceOf({
      tokenAddress: WETH_BASE,
      targetAddress: orchestrator.addressOn(base.id, true),
      constraints: [ balanceNotZeroConstraint ],
    }),
  },
});

4.4 Deposit WETH into Morpho

Deposit WETH to Morpho.

Ordering for this function call depends on two factors:

  • balanceNotZeroConstraint defines that the instruction can't be executed until the swap has happened (meaning until the balance of WETH is greater than zero)
  • Implicit Ordering works here as well. The orchestrator will wait until the approval has been set. This is because this transaction will keep simulating a failure until it gets the approval to spend WETH. So the execution cannot proceed before that happens.
const supplyWeth = await orchestrator.buildComposable({
  type: "default",
  data: {
    abi: [{
      name: "deposit",
      inputs: [
        { name: "assets",   type: "uint256" },
        { name: "receiver", type: "address" },
      ],
      stateMutability: "nonpayable",
      type: "function",
    }],
    to: MORPHO_RE7_POOL,
    chainId: base.id,
    functionName: "deposit",
    args: [
      runtimeERC20BalanceOf({
        tokenAddress: WETH_BASE,
        targetAddress: orchestrator.addressOn(base.id, true),
        constraints: [balanceNotZeroConstraint],
      }),
      orchestrator.addressOn(base.id, true),
    ],
  },
});

🚦 5 Quote & Execute (Fusion)

5.1 Create the Fusion trigger

In order for the orchestrator account to "pull" the funds for orchestration, we must give it an approval to do so. This is done by the trigger param. It tells the orchestrator which token on which chain and which amount to approve.

Read more about triggers

const trigger: Trigger = {
  chainId: base.id,
  tokenAddress: USDC_BASE,
  amount: inputAmount,
};

5.2 Quote the cost

const quote = await meeClient.getFusionQuote({
  trigger,
  feeToken: { address: USDC_BASE, chainId: base.id },
  instructions: [approveUniswap, swapUSDCtoWeth, approveMorpho, supplyWeth],
});

5.3 Execute the flow

const { hash } = await meeClient.executeFusionQuote({ fusionQuote: quote });
console.log(`Explorer: ${getMeeScanLink(hash)}`);

🧾 6 Confirm Atomic Completion

const receipt = await meeClient.waitForSupertransactionReceipt({ hash });
console.log("Batch complete:", receipt);

✅ All instructions succeed, or none are processed. Fully atomic.


📝 Checklist Before Production

  • Replace amountOutMinimum: 0n with real slippage.
  • Dial in gasLimits to optimize quotes.
  • Add cleanup transfer if leftover tokens aren’t desired.
  • Monitor quote accuracy vs. actual gas used.

Enjoy full-chain composability 🎯