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:
- Population: Convert user operations into low-level bundler operations
- Finalization: Optimize operations (merge duplicates, redirect tokens, add skims)
- 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 marketBlue_Borrow
- Borrow assets from a marketBlue_Repay
- Repay borrowed assetsBlue_Withdraw
- Withdraw supplied assetsBlue_SupplyCollateral
- Supply collateralBlue_WithdrawCollateral
- Withdraw collateralBlue_SetAuthorization
- Authorize an addressBlue_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 vaultMetaMorpho_Withdraw
- Withdraw from a vaultMetaMorpho_PublicReallocate
- Trigger public reallocation
ERC20 Operations (Have address
field)
Erc20_Approve
- Standard approvalErc20_Permit
- Gasless approval via signatureErc20_Permit2
- Uniswap Permit2 approvalErc20_Transfer
- Transfer tokensErc20_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 executesteps
: 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 transactionrequirements.signatures
: Permits/Permit2s to signrequirements.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.