Skip to content

Introduction

The bundler-sdk-viem enables atomic execution of complex DeFi operations on Morpho by bundling multiple actions into a single transaction. This eliminates the need for multiple approvals, reduces gas costs, and prevents intermediate state changes that could affect your position.

Prerequisites: This guide assumes you have:

  • Working knowledge of blue-sdk and blue-sdk-viem
  • A configured Viem client
  • Understanding of Morpho's market mechanics
  • Familiarity with TypeScript and async/await patterns

Important: The bundler SDK requires deep understanding of Morpho's transaction flows. Study the SDK tests and reference implementations before building production features.

Architecture Overview

The bundler transforms high-level user operations into optimized transaction bundles through three phases:

  1. Population: Convert user operations into low-level bundler operations
  2. Finalization: Optimize operations (merge duplicates, redirect tokens, add skims)
  3. Encoding: Package operations into executable transactions with signature requirements

Installation

yarn add @morpho-org/bundler-sdk-viem @morpho-org/blue-sdk @morpho-org/morpho-ts @morpho-org/simulation-sdk viem

Core Concepts

Operation Types

The bundler supports three categories of operations:

Blue Operations (No address field)

  • Blue_Supply - Supply assets to a market
  • Blue_Borrow - Borrow assets from a market
  • Blue_Repay - Repay borrowed assets
  • Blue_Withdraw - Withdraw supplied assets
  • Blue_SupplyCollateral - Supply collateral
  • Blue_WithdrawCollateral - Withdraw collateral
  • Blue_SetAuthorization - Authorize an address
  • Blue_FlashLoan - Execute a flash loan

Critical: Blue operations do not have an address field. They always operate on the Morpho contract address.

MetaMorpho Operations (Have address field)

  • MetaMorpho_Deposit - Deposit into a vault
  • MetaMorpho_Withdraw - Withdraw from a vault
  • MetaMorpho_PublicReallocate - Trigger public reallocation

ERC20 Operations (Have address field)

  • Erc20_Approve - Standard approval
  • Erc20_Permit - Gasless approval via signature
  • Erc20_Permit2 - Uniswap Permit2 approval
  • Erc20_Transfer - Transfer tokens
  • Erc20_Wrap - Wrap tokens (e.g., ETH → WETH)
  • Erc20_Unwrap - Unwrap tokens (e.g., WETH → ETH)

Type Guards

Always use the correct type guards when processing operations:

import {
  isBlueBundlerOperation,
  isMetaMorphoBundlerOperation,
  isErc20BundlerOperation,
} from "@morpho-org/bundler-sdk-viem";
 
operations.forEach((operation) => {
  if (isBlueBundlerOperation(operation)) {
    // No address field - operates on Morpho contract
    if ("id" in operation.args) {
      const marketParams = MarketParams.get(operation.args.id);
    }
  }
  
  if (isMetaMorphoBundlerOperation(operation) || isErc20BundlerOperation(operation)) {
    // Has address field
    const { address } = operation;
  }
});

SimulationState

SimulationState represents the complete protocol state at a specific block. It's required for:

  • Simulating operations before execution
  • Validating transaction feasibility
  • Calculating required approvals and transfers
  • Optimizing operation sequences

Getting SimulationState: Reference production implementations like compound-blue to understand proper state fetching patterns.

Critical Security Practices

1. Never Approve Bundler 3

NEVER set token approvals for Bundler 3 directly. This is a critical security risk. See the official security notice.

The bundler SDK handles approvals correctly through Permit/Permit2 flows.

2. Always Skim Residual Tokens

Every transaction involving tokens must include skim operations to return any residual tokens to the user. Failing to skim can result in:

  • Lost user funds trapped in adapters/bundler
  • Transaction surplus not returned to users
  • Poor user experience
// Always skim both input and output tokens
const skimOperations = [
  {
    type: "Erc20_Transfer",
    sender: user,
    address: tokenAddress,
    args: {
      recipient: user,
      amount: MaxUint256, // Transfers all remaining balance
    }
  }
];

3. Handle Swap Surplus Correctly

When using DEX integrations (e.g., exact output swaps), you may receive more tokens than expected. Always skim both:

  • The token you sent
  • The token you received

Example from production:

// Skim shares back to user
{
  type: "Erc20_Transfer",
  args: [vaultAddress, account.address, maxUint256, generalAdapter1, false]
},
// Skim asset tokens back to user
{
  type: "Erc20_Transfer",
  args: [vaultAsset.address, account.address, maxUint256, generalAdapter1, false]
}

Key Functions

populateBundle

Converts high-level user operations into a bundle of low-level operations by simulating each action.

const { operations, steps } = populateBundle(
  inputOperations: InputBundlerOperation[],
  simulationState: SimulationState,
  bundlingOptions?: BundlingOptions
);

Returns:

  • operations: Array of bundler operations to execute
  • steps: Simulation steps for debugging

finalizeBundle

Optimizes the operation bundle by merging duplicates, redirecting tokens, and adding necessary skims.

const optimizedOperations = finalizeBundle(
  operations: BundlerOperation[],
  simulationState: SimulationState,
  receiverAddress: Address,
  unwrapTokens?: Set<Address>,
  unwrapSlippage?: bigint
);

Key optimizations:

  • Merges duplicate ERC20 approvals
  • Redirects token flows when possible
  • Adds skim operations for residual tokens
  • Handles token unwrapping with slippage protection

encodeBundle

Packages optimized operations into an executable transaction with signature requirements.

const bundle = encodeBundle(
  operations: BundlerOperation[],
  simulationState: SimulationState,
  supportsSignature?: boolean
);

Returns: ActionBundle containing:

  • tx(): Function to get the main transaction
  • requirements.signatures: Permits/Permit2s to sign
  • requirements.txs: Prerequisite transactions (if any)

Bundling Options

Fine-tune bundler behavior with BundlingOptions:

interface BundlingOptions {
  // Tokens to use simple permit (not Permit2)
  withSimplePermit?: Set<Address>;
  
  // Enable public allocator for liquidity reallocation
  publicAllocatorOptions?: {
    enabled: boolean;
    supplyTargetUtilization?: Record<MarketId, bigint>;
    withdrawTargetUtilization?: Record<MarketId, bigint>;
    defaultSupplyTargetUtilization?: bigint;
    defaultWithdrawTargetUtilization?: bigint;
  };
  
  // Custom function to handle required token amounts
  getRequirementOperations?: (
    requiredTokenAmounts: Record<Address, bigint>
  ) => InputBundlerOperation[];
}

Public Allocator Example

The public allocator automatically reallocates vault liquidity when market utilization exceeds thresholds:

const bundlingOptions: BundlingOptions = {
  publicAllocatorOptions: {
    enabled: true,
    supplyTargetUtilization: {
      [marketId]: 905000000000000000n, // 90.5% target for specific market
    },
    defaultSupplyTargetUtilization: 900000000000000000n, // 90% default
  },
};

Production Implementation Pattern

Here's the recommended pattern for building bundler transactions:

import { type Account, WalletClient, zeroAddress } from "viem";
import { parseAccount } from "viem/accounts";
import {
  type Address,
  DEFAULT_SLIPPAGE_TOLERANCE,
  MarketId,
  MarketParams,
  UnknownMarketParamsError,
  getUnwrappedToken,
} from "@morpho-org/blue-sdk";
import {
  type BundlingOptions,
  type InputBundlerOperation,
  encodeBundle,
  finalizeBundle,
  populateBundle,
  isBlueBundlerOperation,
  isMetaMorphoBundlerOperation,
  isErc20BundlerOperation,
} from "@morpho-org/bundler-sdk-viem";
import "@morpho-org/blue-sdk-viem/lib/augment";
import { SimulationState } from "@morpho-org/simulation-sdk";
 
export const setupBundle = async (
  client: WalletClient,
  startData: SimulationState,
  inputOperations: InputBundlerOperation[],
  {
    account: account_ = client.account,
    supportsSignature,
    unwrapTokens,
    unwrapSlippage,
    onBundleTx,
    ...options
  }: BundlingOptions & {
    account?: Address | Account;
    supportsSignature?: boolean;
    unwrapTokens?: Set<Address>;
    unwrapSlippage?: bigint;
    onBundleTx?: (data: SimulationState) => Promise<void> | void;
  } = {}
) => {
  if (!account_) throw new Error("Account is required");
  const account = parseAccount(account_);
 
  // Phase 1: Populate bundle with simulated operations
  let { operations } = populateBundle(inputOperations, startData, {
    ...options,
    withSimplePermit: options?.withSimplePermit,
    publicAllocatorOptions: {
      enabled: true,
      ...options.publicAllocatorOptions,
    },
  });
 
  // Phase 2: Finalize bundle (optimize and add skims)
  operations = finalizeBundle(
    operations,
    startData,
    account.address,
    unwrapTokens,
    unwrapSlippage
  );
 
  // Phase 3: Encode bundle for execution
  const bundle = encodeBundle(operations, startData, supportsSignature);
 
  // Log bundle composition (useful for debugging)
  console.log(`\nBundle: ${operations.length} operations`);
  const operationsByType = operations.reduce((acc, op) => {
    acc[op.type] = (acc[op.type] || 0) + 1;
    return acc;
  }, {} as Record<string, number>);
  
  Object.entries(operationsByType).forEach(([type, count]) => {
    console.log(`  ${type}: ${count}`);
  });
 
  // Collect all tokens involved (for monitoring/analytics)
  const tokens = new Set<Address>();
  operations.forEach((operation) => {
    if (isBlueBundlerOperation(operation) && "id" in operation.args) {
      try {
        const marketParams = MarketParams.get(operation.args.id);
        if (marketParams.loanToken !== zeroAddress) tokens.add(marketParams.loanToken);
        if (marketParams.collateralToken !== zeroAddress) tokens.add(marketParams.collateralToken);
      } catch (error) {
        if (!(error instanceof UnknownMarketParamsError)) throw error;
      }
    }
 
    if (isMetaMorphoBundlerOperation(operation)) {
      const { address } = operation;
      tokens.add(address);
      const vault = startData.tryGetVault(address);
      if (vault) tokens.add(vault.asset);
    }
 
    if (isErc20BundlerOperation(operation)) {
      const { address } = operation;
      tokens.add(address);
      const unwrapped = getUnwrappedToken(address, startData.chainId);
      if (unwrapped != null) tokens.add(unwrapped);
    }
  });
 
  console.log(`Tokens: ${tokens.size} unique`);
 
  // Callback before signing (for UI updates)
  await onBundleTx?.(startData);
 
  // Sign required permits/permit2s
  if (bundle.requirements.signatures.length > 0) {
    console.log(`\nSignatures required: ${bundle.requirements.signatures.length}`);
    await Promise.all(
      bundle.requirements.signatures.map((requirement) =>
        requirement.sign(client, account)
      )
    );
  }
 
  // Execute all transactions (prerequisites + main bundle)
  const txs = bundle.requirements.txs.map(({ tx }) => tx).concat([bundle.tx()]);
  const txHashes: `0x${string}`[] = [];
 
  for (let i = 0; i < txs.length; i++) {
    const tx = txs[i];
    const isMainBundle = i === txs.length - 1;
    
    console.log(`\nTx ${i + 1}/${txs.length} ${isMainBundle ? '[Bundle]' : '[Prerequisite]'}`);
    
    const hash = await client.sendTransaction({ ...tx, account } as any);
    txHashes.push(hash);
    console.log(`  Hash: ${hash}`);
  }
 
  console.log("\nAll transactions submitted");
 
  return { operations, bundle, txHashes };
};

Common Patterns

Supply + Supply Collateral + Borrow

export const supplySupplyCollateralBorrow = async (
  marketId: MarketId,
  client: WalletClient,
  simulationState: SimulationState,
  amountSupply: bigint,
  amountSupplyCollateral: bigint,
  amountBorrow: bigint
) => {
  const user = client.account?.address;
  if (!user) throw new Error("User address is required");
 
  return setupBundle(client, simulationState, [
    {
      type: "Blue_Supply",
      sender: user,
      args: {
        id: marketId,
        assets: amountSupply,
        onBehalf: user,
        slippage: DEFAULT_SLIPPAGE_TOLERANCE,
      },
    },
    {
      type: "Blue_SupplyCollateral",
      sender: user,
      args: {
        id: marketId,
        assets: amountSupplyCollateral,
        onBehalf: user,
      },
    },
    {
      type: "Blue_Borrow",
      sender: user,
      args: {
        id: marketId,
        assets: amountBorrow,
        onBehalf: user,
        receiver: user,
        slippage: DEFAULT_SLIPPAGE_TOLERANCE,
      },
    },
  ]);
};

Error Handling

Always wrap bundler operations in comprehensive error handling:

try {
  const result = await setupBundle(client, simulationState, operations);
  
  // Monitor transaction confirmations
  for (const hash of result.txHashes) {
    const receipt = await client.waitForTransactionReceipt({ hash });
    if (receipt.status !== 'success') {
      throw new Error(`Transaction failed: ${hash}`);
    }
  }
  
  return result;
} catch (error) {
  console.error("Bundle execution failed");
  console.error(`  Market: ${marketId}`);
  console.error(`  Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
  
  // Log operation details for debugging
  if (operations.length > 0) {
    console.error(`  Operations: ${operations.map(op => op.type).join(', ')}`);
  }
  
  throw error;
}

Browser Compatibility Warning

Some browsers (notably Opera) perform aggressive optimizations that reduce API calls and data fetching. This can cause:

  • Stale data being used in transactions
  • Missing real-time price updates
  • Transaction failures due to outdated state

Mitigation strategies:

  • Add data staleness checks before transaction execution
  • Implement fallback data fetching for critical operations
  • Consider blocking certain browsers for high-value transactions
  • Add explicit "refresh data" options in UI

Reference Implementations

Study these production implementations to understand real-world patterns:

compound-blue by Paperclip Labs

  • Repository
  • Demonstrates explicit transaction building
  • Clean deposit/withdraw/supply/borrow flows
  • Good example of simulation state management: getSimulationState.ts

SDK Tests

  • bundler-sdk-viem tests
  • Comprehensive coverage of all operation types
  • Edge case handling examples
  • Not documented elsewhere but extremely valuable

Performance Optimization

Caching with Apollo/GraphQL

Use Morpho's SDK cache policies for efficient data fetching:

import { typePolicies } from "@morpho-org/blue-api-sdk";
import { InMemoryCache } from "@apollo/client";
 
export const inMemoryCache = new InMemoryCache({ typePolicies });

The SDK provides pre-configured cache transformations and merge functions. Reference: blue-api-sdk

SDK Utility Functions

Leverage built-in helpers instead of building from scratch:

  • Direct price fetching
  • Rate calculations
  • Off-chain price computations
  • Market data aggregation

These utilities significantly accelerate development and ensure consistency with the protocol.

Complete Working Example

import { type Account, WalletClient, zeroAddress } from "viem";
import { parseAccount } from "viem/accounts";
import {
  type Address,
  DEFAULT_SLIPPAGE_TOLERANCE,
  MarketId,
  MarketParams,
  UnknownMarketParamsError,
  getUnwrappedToken,
} from "@morpho-org/blue-sdk";
import {
  type BundlingOptions,
  type InputBundlerOperation,
  encodeBundle,
  finalizeBundle,
  populateBundle,
  isBlueBundlerOperation,
  isMetaMorphoBundlerOperation,
  isErc20BundlerOperation,
} from "@morpho-org/bundler-sdk-viem";
import "@morpho-org/blue-sdk-viem/lib/augment";
import { SimulationState } from "@morpho-org/simulation-sdk";
 
/**
 * Core bundle setup function
 * Handles the complete lifecycle: populate → finalize → encode → sign → execute
 */
export const setupBundle = async (
  client: WalletClient,
  startData: SimulationState,
  inputOperations: InputBundlerOperation[],
  {
    account: account_ = client.account,
    supportsSignature,
    unwrapTokens,
    unwrapSlippage,
    onBundleTx,
    ...options
  }: BundlingOptions & {
    account?: Address | Account;
    supportsSignature?: boolean;
    unwrapTokens?: Set<Address>;
    unwrapSlippage?: bigint;
    onBundleTx?: (data: SimulationState) => Promise<void> | void;
  } = {}
) => {
  if (!account_) throw new Error("Account is required");
  const account = parseAccount(account_);
 
  // Phase 1: Populate bundle with simulated operations
  let { operations } = populateBundle(inputOperations, startData, {
    ...options,
    withSimplePermit: options?.withSimplePermit,
    publicAllocatorOptions: {
      enabled: true,
      ...options.publicAllocatorOptions,
    },
  });
 
  // Phase 2: Finalize bundle (optimize and add skims)
  operations = finalizeBundle(
    operations,
    startData,
    account.address,
    unwrapTokens,
    unwrapSlippage
  );
 
  // Phase 3: Encode bundle for execution
  const bundle = encodeBundle(operations, startData, supportsSignature);
 
  // Log bundle composition
  console.log(`\nBundle: ${operations.length} operations`);
  const operationsByType = operations.reduce((acc, op) => {
    acc[op.type] = (acc[op.type] || 0) + 1;
    return acc;
  }, {} as Record<string, number>);
  
  Object.entries(operationsByType).forEach(([type, count]) => {
    console.log(`  ${type}: ${count}`);
  });
 
  // Collect all involved tokens
  const tokens = new Set<Address>();
  operations.forEach((operation) => {
    if (isBlueBundlerOperation(operation) && "id" in operation.args) {
      try {
        const marketParams = MarketParams.get(operation.args.id);
        if (marketParams.loanToken !== zeroAddress) tokens.add(marketParams.loanToken);
        if (marketParams.collateralToken !== zeroAddress) tokens.add(marketParams.collateralToken);
      } catch (error) {
        if (!(error instanceof UnknownMarketParamsError)) throw error;
      }
    }
 
    if (isMetaMorphoBundlerOperation(operation)) {
      const { address } = operation;
      tokens.add(address);
      const vault = startData.tryGetVault(address);
      if (vault) tokens.add(vault.asset);
    }
 
    if (isErc20BundlerOperation(operation)) {
      const { address } = operation;
      tokens.add(address);
      const unwrapped = getUnwrappedToken(address, startData.chainId);
      if (unwrapped != null) tokens.add(unwrapped);
    }
  });
 
  console.log(`Tokens: ${tokens.size} unique`);
 
  // Execute callback before signing
  await onBundleTx?.(startData);
 
  // Sign required permits/permit2s
  if (bundle.requirements.signatures.length > 0) {
    console.log(`\nSignatures required: ${bundle.requirements.signatures.length}`);
    bundle.requirements.signatures.forEach((req, i) => {
      console.log(`  ${i + 1}. ${req.action.type}`);
    });
    
    await Promise.all(
      bundle.requirements.signatures.map((requirement) =>
        requirement.sign(client, account)
      )
    );
  }
 
  // Execute all transactions
  const txs = bundle.requirements.txs.map(({ tx }) => tx).concat([bundle.tx()]);
  const txHashes: `0x${string}`[] = [];
 
  for (let i = 0; i < txs.length; i++) {
    const tx = txs[i];
    const isMainBundle = i === txs.length - 1;
    
    console.log(`\nTx ${i + 1}/${txs.length} ${isMainBundle ? '[Bundle]' : '[Prerequisite]'}`);
    console.log(`  To: ${tx.to}`);
    if (tx.value && tx.value > 0n) {
      console.log(`  Value: ${tx.value}`);
    }
    
    const hash = await client.sendTransaction({ ...tx, account } as any);
    txHashes.push(hash);
    console.log(`  Hash: ${hash}`);
  }
 
  console.log("\nAll transactions submitted");
 
  return { operations, bundle, txHashes };
};
 
/**
 * Example: Supply assets, supply collateral, and borrow in one transaction
 * This pattern is commonly used for opening leveraged positions
 */
export const supplySupplyCollateralBorrow = async (
  marketId: MarketId,
  client: WalletClient,
  simulationState: SimulationState,
  amountSupply: bigint,
  amountSupplyCollateral: bigint,
  amountBorrow: bigint
) => {
  const user = client.account?.address;
  if (!user) throw new Error("User address is required");
 
  console.log("\nOperation: Supply + SupplyCollateral + Borrow");
  console.log(`Market: ${marketId}`);
  console.log(`User: ${user}`);
  console.log(`Amounts: ${amountSupply} (supply), ${amountSupplyCollateral} (collateral), ${amountBorrow} (borrow)`);
 
  return setupBundle(client, simulationState, [
    {
      type: "Blue_Supply",
      sender: user,
      args: {
        id: marketId,
        assets: amountSupply,
        onBehalf: user,
        slippage: DEFAULT_SLIPPAGE_TOLERANCE,
      },
    },
    {
      type: "Blue_SupplyCollateral",
      sender: user,
      args: {
        id: marketId,
        assets: amountSupplyCollateral,
        onBehalf: user,
      },
    },
    {
      type: "Blue_Borrow",
      sender: user,
      args: {
        id: marketId,
        assets: amountBorrow,
        onBehalf: user,
        receiver: user,
        slippage: DEFAULT_SLIPPAGE_TOLERANCE,
      },
    },
  ]);
};
 
/**
 * Example: Repay debt and withdraw collateral in one transaction
 * Useful for closing or reducing leveraged positions
 */
export const repayAndWithdrawCollateral = async (
  marketId: MarketId,
  client: WalletClient,
  simulationState: SimulationState,
  repayAmount: bigint,
  withdrawCollateralAmount: bigint
) => {
  const user = client.account?.address;
  if (!user) throw new Error("User address is required");
 
  console.log("\nOperation: Repay + WithdrawCollateral");
  console.log(`Market: ${marketId}`);
  console.log(`Amounts: ${repayAmount} (repay), ${withdrawCollateralAmount} (withdraw collateral)`);
 
  return setupBundle(client, simulationState, [
    {
      type: "Blue_Repay",
      sender: user,
      args: {
        id: marketId,
        assets: repayAmount,
        onBehalf: user,
        slippage: DEFAULT_SLIPPAGE_TOLERANCE,
      },
    },
    {
      type: "Blue_WithdrawCollateral",
      sender: user,
      args: {
        id: marketId,
        assets: withdrawCollateralAmount,
        onBehalf: user,
        receiver: user,
      },
    },
  ]);
};
 
/**
 * Example usage with error handling and transaction monitoring
 */
export const executeBundle = async () => {
  try {
    // Fetch current simulation state
    // See: https://github.com/papercliplabs/compound-blue/blob/main/src/actions/data/rpc/getSimulationState.ts
    const simulationState = await getSimulationState(client);
    
    // Execute bundle
    const result = await supplySupplyCollateralBorrow(
      marketId,
      client,
      simulationState,
      parseUnits("1000", 18), // Supply 1000 tokens
      parseUnits("500", 18),  // Supply 500 collateral
      parseUnits("200", 18)   // Borrow 200 tokens
    );
    
    console.log(`\nBundle execution initiated`);
    console.log(`Operations: ${result.operations.length}`);
    console.log(`Transactions: ${result.txHashes.length}`);
    
    // Wait for confirmations
    for (const hash of result.txHashes) {
      console.log(`\nWaiting for confirmation: ${hash}`);
      const receipt = await client.waitForTransactionReceipt({ hash });
      
      if (receipt.status === 'success') {
        console.log(`✓ Transaction confirmed`);
      } else {
        throw new Error(`Transaction failed: ${hash}`);
      }
    }
    
    console.log("\n✓ All transactions confirmed successfully");
    return result;
    
  } catch (error) {
    console.error("\n✗ Bundle execution failed");
    console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
    throw error;
  }
};

Additional Resources

Summary

The bundler-sdk-viem enables atomic execution of complex DeFi operations with:

  • Explicit transaction construction for transparency and safety
  • Automatic optimization of operations and gas costs
  • Built-in security through proper approval and skim handling
  • Production-ready patterns from reference implementations

Master the fundamentals by studying SDK tests and reference implementations before building production features. Always skim residual tokens, never approve Bundler 3 directly, and handle edge cases explicitly.