Skip to Content
RecipesMorpho Yield Optimizer Agent

Morpho Yield Optimizer Agent

This agent manages a multi-vault Morpho portfolio. It ranks every vault accepting your chosen asset, splits capital equally across the top N, rebalances when allocations drift, sweeps idle funds to Aave V3 as a yield floor, and claims accrued Morpho rewards on a 24-hour cadence. You stay in control — large transactions require your approval via Telegram.

Looking for the quick version? Check out the Morpho Yield Optimizer Starter — no code, 2 minutes.

Keep yourself safe

Before you start:

  • Start in dry-run mode. The starter defaults to AGENT_DRY_RUN=1. Transactions are logged but not sent. Only set AGENT_DRY_RUN=0 after everything looks right.
  • Run your agent in Docker. AI agents can execute arbitrary code. Running in a container isolates the agent from your host machine.
  • Set a low daily spend limit. This recipe defaults to $10/day. Increase deliberately after you are comfortable.
  • Enable 2FA before funding. Set up Telegram approvals before sending any funds.
  • Don’t store API keys in code. Use .env files and add .env to .gitignore.
  • Use a separate wallet for testing. Don’t connect the agent to your main wallet.

Full safety guide: Safety & Architecture

What you will need

  • Node.js 20+ (install )
  • Docker (install ) — recommended for running agents safely
  • A Telegram account (for approvals and optional alerts)
  • A small amount of the asset you want to deposit (for example, USDC on Base)
  • Native gas token for your chain (ETH on Base/Ethereum/Arbitrum)

Setup

Do this once. If you have already set up waap-cli from another recipe, skip to The recipe.

# Install WaaP CLI npm install -g @human.tech/waap-cli@latest # Create an agent wallet waap-cli signup --email you+morpho-agent@example.com --password 'YourS3cur3Pass!' # Verify your wallet was created waap-cli whoami # => 0xYOUR_AGENT_ADDRESS # Enable 2FA via Telegram (do this BEFORE funding) waap-cli 2fa enable --telegram YOUR_TELEGRAM_CHAT_ID # Set a conservative daily spend limit waap-cli policy set --daily-spend-limit 10 # Check everything is configured waap-cli session-info

What success looks like: waap-cli whoami prints an Ethereum address. waap-cli session-info shows your 2FA method as telegram and daily spend limit as $10.

Now fund your agent wallet by sending a small amount of your target asset (for example, USDC) plus native gas token to the address from waap-cli whoami.

The recipe

Create a new project directory:

mkdir morpho-yield-optimizer && cd morpho-yield-optimizer npm init -y npm install dotenv execa tsx undici viem

Create the following files:

agent.ts

This is the full agent. The sections below walk through each piece.

Configuration

The agent reads all settings from environment variables. Key parameters:

  • AGENT_ASSET — the ERC-20 address the agent deposits (for example, USDC on Base)
  • AGENT_PORTFOLIO_TOP_N — how many top vaults to spread across (default: 3)
  • AGENT_REBAL_DRIFT_BPS — how far a leg can drift before rebalancing, in basis points of total portfolio value (default: 500, which means 5%)
  • AGENT_DRY_RUN — set to 0 for live transactions, anything else keeps dry-run on
import 'dotenv/config' import { execa } from 'execa' import { request } from 'undici' import { createPublicClient, http, encodeFunctionData, parseAbi, formatUnits, type Hex, type Chain, } from 'viem' import { mainnet, base, arbitrum, optimism, polygon, sepolia } from 'viem/chains' import fs from 'node:fs' const AGENT_ID = process.env.AGENT_ID || 'morpho-yield-optimizer' const CHAIN_ID = Number(process.env.CHAIN_ID ?? 8453) const API_URL = process.env.MORPHO_API_URL ?? 'https://api.morpho.org/graphql' const RPC_URL = process.env.RPC_URL const ASSET = (process.env.AGENT_ASSET ?? process.env.ASSET_ADDRESS) as Hex | undefined const MAX_DEPOSIT_USD = Number(process.env.AGENT_MAX_DEPOSIT_USD ?? 0) const POLL_MS = Number(process.env.AGENT_POLL_INTERVAL_MS ?? 30 * 60 * 1000) const TOP_N = Math.max(1, Number(process.env.AGENT_PORTFOLIO_TOP_N ?? 3)) const REBAL_DRIFT_BPS = Math.max(50, Number(process.env.AGENT_REBAL_DRIFT_BPS ?? 500)) const DRY_RUN = process.env.AGENT_DRY_RUN !== '0'

ABI setup with viem

Instead of hand-encoding calldata, the agent uses viem’s parseAbi to define the ERC-20, ERC-4626 (Morpho MetaMorpho vaults), Aave V3, and Morpho rewards distributor function signatures:

const abi = parseAbi([ 'function allowance(address owner, address spender) view returns (uint256)', 'function balanceOf(address owner) view returns (uint256)', 'function decimals() view returns (uint8)', 'function approve(address spender, uint256 value) returns (bool)', 'function deposit(uint256 assets, address receiver) returns (uint256 shares)', 'function withdraw(uint256 assets, address receiver, address owner) returns (uint256 shares)', 'function redeem(uint256 shares, address receiver, address owner) returns (uint256 assets)', 'function convertToAssets(uint256 shares) view returns (uint256 assets)', ]) const aaveAbi = parseAbi([ 'function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode)', 'function withdraw(address asset, uint256 amount, address to) returns (uint256)', ]) const urdAbi = parseAbi([ 'function claim(address account, address reward, uint256 claimable, bytes32[] proof) returns (uint256 amount)', ])

encodeFunctionData then produces the correct calldata without manual hex padding. For example:

const data = encodeFunctionData({ abi, functionName: 'deposit', args: [amount, ownerAddress], })

WaaP CLI helpers

The agent calls waap-cli via execa for wallet operations. The key never leaves the 2PC split — the CLI handles signing against the enclave:

async function whoami(): Promise<Hex> { const override = process.env.WAAP_AGENT_ADDRESS?.trim() if (override) return override as Hex const { stdout } = await execa('waap-cli', ['whoami', '--json']) // Parse the JSON output to extract the EVM address const lines = stdout.split(/\r?\n/).filter((l) => l.trim().startsWith('{')) for (const line of lines) { try { const obj = JSON.parse(line) as { evmWalletAddress?: string } if (obj.evmWalletAddress) return obj.evmWalletAddress as Hex } catch {} } throw new Error('no EVM wallet address') } async function sendTx(to: Hex, data: Hex, label: string): Promise<string> { if (DRY_RUN) { log('info', 'dry_run_skip', { label, to }) return '0xdryrun' } const args = [ 'send-tx', '--to', to, '--value', '0', '--data', data, '--chain', `evm:${CHAIN_ID}`, ] if (RPC_URL) args.push('--rpc', RPC_URL) const { stdout } = await execa('waap-cli', args) const match = stdout.match(/0x[a-fA-F0-9]{64}/) if (!match) throw new Error(`Could not extract tx hash: ${stdout.slice(0, 200)}`) return match[0] }

Fetching vault data from the Morpho API

The agent queries the Morpho GraphQL API to get vault APYs, TVL, and asset prices. If you set WATCHED_VAULTS in your .env, it queries only those addresses. Otherwise, it queries all vaults that accept your AGENT_ASSET on the configured chain:

interface MorphoVault { address: Hex symbol: string name: string state?: { netApy?: number; apy?: number; totalAssetsUsd?: number } asset: { address: Hex; symbol?: string; decimals: number; priceUsd?: number } } async function fetchVaults(): Promise<MorphoVault[]> { if (WATCHED_VAULTS.length > 0) { // Query each vault individually by address const items: MorphoVault[] = [] for (const vaultAddress of WATCHED_VAULTS) { const q = { query: `query($address: String!, $chainId: Int!) { vaultByAddress(address: $address, chainId: $chainId) { address symbol name state { netApy apy totalAssetsUsd } asset { address symbol decimals priceUsd } } }`, variables: { address: vaultAddress, chainId: CHAIN_ID }, } const res = await request(API_URL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(q), }) const body = (await res.body.json()) as { data?: { vaultByAddress?: MorphoVault | null } } if (body.data?.vaultByAddress) items.push(body.data.vaultByAddress) } return items } // No allowlist -- query by asset on this chain const q = { query: `query($asset: String!, $chainId: Int!) { vaults(where: { assetAddress_in: [$asset], chainId_in: [$chainId] }, first: 50) { items { address symbol name state { netApy apy totalAssetsUsd } asset { address symbol decimals priceUsd } } } }`, variables: { asset: ASSET, chainId: CHAIN_ID }, } const res = await request(API_URL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(q), }) const body = (await res.body.json()) as { data?: { vaults?: { items?: MorphoVault[] } } } return body.data?.vaults?.items ?? [] }

Multi-vault portfolio rebalancing

This is the heart of the agent. Each tick:

  1. Ranks all vaults by net APY and picks the top N.
  2. Calculates the equal-weight target for each leg (totalPortfolioValue / N).
  3. Exits any held position that dropped out of the top N.
  4. For each target leg, measures drift from the target weight in basis points. If drift exceeds REBAL_DRIFT_BPS, it either tops up (deposits more) or trims (withdraws excess).
async function tick(owner: Hex): Promise<void> { const decimals = await assetDecimals() const vaults = (await fetchVaults()) .filter((v) => typeof v.state?.netApy === 'number') if (vaults.length === 0) return vaults.sort((a, b) => b.state!.netApy! - a.state!.netApy!) const targetVaults = vaults.slice(0, Math.min(TOP_N, vaults.length)) // Reconcile: check on-chain balances in all fetched vaults const heldList = await reconcilePositions(owner, vaults) const idleAssets = await assetBalance(owner) // Calculate total portfolio value (held + idle + Aave floor) const priceUsd = targetVaults[0].asset.priceUsd ?? 1 const heldUsd = heldList.reduce((sum, p) => { const px = p.vault.asset.priceUsd ?? 1 return sum + Number(formatUnits(p.assets, p.vault.asset.decimals)) * px }, 0) const totalUsd = heldUsd + priceUsd * Number(formatUnits(idleAssets, decimals)) + priceUsd * Number(formatUnits(await aaveSuppliedAssets(owner), decimals)) const perLegTargetUsd = Math.min(totalUsd, MAX_DEPOSIT_USD) / targetVaults.length const targetAddresses = new Set(targetVaults.map((v) => v.address.toLowerCase())) // 1. Exit stale legs for (const held of heldList) { if (targetAddresses.has(held.vault.address.toLowerCase())) continue await withdrawFrom(held.vault, owner, held.assets) } // 2. Drift-correct each target leg for (const target of targetVaults) { const held = heldList.find( (p) => p.vault.address.toLowerCase() === target.address.toLowerCase(), ) const heldUsdLeg = held ? Number(formatUnits(held.assets, target.asset.decimals)) * (target.asset.priceUsd ?? 1) : 0 const driftUsd = perLegTargetUsd - heldUsdLeg const driftBps = totalUsd > 0 ? Math.round((Math.abs(driftUsd) / totalUsd) * 10_000) : 0 if (driftBps < REBAL_DRIFT_BPS) continue if (driftUsd > 0) { // Under-allocated: top up from idle, pulling from Aave if needed const wantAmount = toBaseUnits(driftUsd, target.asset.priceUsd ?? 1, decimals) let currentIdle = await assetBalance(owner) if (currentIdle < wantAmount && IDLE_FALLBACK_ENABLED) { // Pull shortfall from Aave floor const aaveBal = await aaveSuppliedAssets(owner) const pullAmount = aaveBal < (wantAmount - currentIdle) ? aaveBal : (wantAmount - currentIdle) if (pullAmount > 0n) await aaveWithdraw(owner, pullAmount, decimals, priceUsd) currentIdle = await assetBalance(owner) } const amount = currentIdle < wantAmount ? currentIdle : wantAmount if (amount > 0n) await depositInto(target, owner, amount) } else { // Over-allocated: trim const excessAmount = toBaseUnits(-driftUsd, target.asset.priceUsd ?? 1, target.asset.decimals) if (excessAmount > 0n && held) await withdrawFrom(target, owner, excessAmount) } } // 3. Sweep remaining idle to Aave if (IDLE_FALLBACK_ENABLED) { const finalIdle = await assetBalance(owner) const finalIdleUsd = priceUsd * Number(formatUnits(finalIdle, decimals)) if (finalIdleUsd >= IDLE_FALLBACK_MIN_USD) { await aaveSupply(owner, finalIdle, decimals, priceUsd) } } }

Aave V3 idle-fallback floor

When all Morpho legs are at their target weight and idle asset remains in the wallet, the agent sweeps it into the Aave V3 lending pool. This keeps idle funds productive rather than sitting in the wallet earning nothing. When a Morpho leg later needs a top-up and the wallet balance is insufficient, the agent withdraws from Aave first.

To enable, set both AAVE_POOL_ADDRESS and AAVE_ATOKEN_ADDRESS in your .env:

# Base USDC Aave V3 addresses AAVE_POOL_ADDRESS=0xA238Dd80C259a72e81d7e4664a9801593F98d1c5 AAVE_ATOKEN_ADDRESS=0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB

The supply/withdraw logic:

async function aaveSupply( owner: Hex, amount: bigint, decimals: number, priceUsd: number, ): Promise<void> { await ensureApproval(AAVE_POOL as Hex, amount, owner) const data = encodeFunctionData({ abi: aaveAbi, functionName: 'supply', args: [ASSET!, amount, owner, 0], }) await sendTx(AAVE_POOL as Hex, data, `aave supply ${formatUnits(amount, decimals)}`) } async function aaveWithdraw( owner: Hex, amount: bigint, decimals: number, priceUsd: number, ): Promise<bigint> { const data = encodeFunctionData({ abi: aaveAbi, functionName: 'withdraw', args: [ASSET!, amount, owner], }) await sendTx(AAVE_POOL as Hex, data, `aave withdraw ${formatUnits(amount, decimals)}`) return amount }

Morpho reward claiming

Morpho distributes rewards to vault depositors through a Universal Rewards Distributor (URD). The agent queries a rewards API for claimable amounts, then submits claim() calls with Merkle proofs. This runs on its own schedule (default: every 24 hours) independent of the rebalance loop.

To enable, set REWARDS_API_URL in your .env:

REWARDS_API_URL=https://rewards.morpho.org/v1/users/{address}?chain_id={chainId}

The {address} and {chainId} placeholders are replaced at runtime with the agent’s wallet address and the configured chain ID.

async function claimRewardsOnce(owner: Hex): Promise<void> { const claims = await fetchClaimableRewards(owner) if (claims.length === 0) return for (const c of claims) { const claimable = BigInt(c.claimable) if (claimable < REWARDS_MIN_CLAIM_WEI) continue const data = encodeFunctionData({ abi: urdAbi, functionName: 'claim', args: [owner, c.reward, claimable, c.proof], }) await sendTx(c.distributor, data, `claim ${c.symbol ?? c.reward}`) } }

The reward claim runs in the main loop alongside the rebalance tick, on its own timer:

if (REWARDS_CLAIM_ENABLED && Date.now() - lastClaimAt >= REWARDS_CLAIM_INTERVAL_MS) { await claimRewardsOnce(owner) lastClaimAt = Date.now() }

Telegram and Matrix alerts

The agent can send fire-and-forget notifications to Telegram and/or Matrix on rebalance events, reward claims, and Aave sweep/withdraw actions. These never block the main loop — if the alert fails, it logs a warning and moves on.

For Telegram, set TG_BOT_TOKEN and TG_CHAT_ID. For Matrix, set MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN, and MATRIX_ALERT_ROOM.

async function sendAlert(message: string): Promise<void> { if (TG_ALERTS_ENABLED) { try { await fetch(`https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: TG_CHAT_ID, text: `[${AGENT_ID}] ${message}`, }), }) } catch {} } // Matrix alert (similar pattern, see full source) }

Alerts are called with void sendAlert(...) (fire-and-forget) so they never delay transaction execution.

.env

# ERC-20 asset address (USDC on Base) AGENT_ASSET=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 AGENT_MAX_DEPOSIT_USD=100 # Chain CHAIN_ID=8453 # Strategy AGENT_PORTFOLIO_TOP_N=3 AGENT_REBAL_DRIFT_BPS=500 AGENT_POLL_INTERVAL_MS=1800000 # Safety -- dry-run on by default AGENT_DRY_RUN=1

package.json

{ "name": "morpho-yield-optimizer", "version": "2.0.0", "private": true, "type": "module", "scripts": { "start": "tsx agent.ts", "dev": "tsx agent.ts" }, "dependencies": { "dotenv": "^16.4.5", "execa": "^9.5.2", "tsx": "^4.7.0", "undici": "^6.0.0", "viem": "^2.21.0" }, "devDependencies": { "@types/node": "^20.0.0", "typescript": "^5.0.4" } }

Dockerfile

FROM node:20-alpine RUN npm install -g @human.tech/waap-cli@latest WORKDIR /app RUN chown -R node:node /app USER node COPY --chown=node:node package*.json ./ RUN npm install --omit=dev COPY --chown=node:node . . CMD ["npm", "run", "start"]

docker-compose.yml

services: morpho-agent: build: . env_file: .env restart: unless-stopped volumes: - ${HOME}/.waap-cli:/root/.waap-cli

See it work

npm install npm start

In dry-run mode (the default), you will see log output like this:

{"ts":"2026-05-08T14:00:01.000Z","agent":"morpho-yield-optimizer","level":"info","message":"agent_starting","chainId":8453,"asset":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","dryRun":true,"topN":3} {"ts":"2026-05-08T14:00:02.000Z","agent":"morpho-yield-optimizer","level":"info","message":"cycle","vaultsConsidered":12,"topN":3,"targets":[{"symbol":"steakUSDC","apyBps":842},{"symbol":"mwUSDC","apyBps":789},{"symbol":"re7USDC","apyBps":715}]} {"ts":"2026-05-08T14:00:02.500Z","agent":"morpho-yield-optimizer","level":"event","message":"portfolio_drift","vaultSymbol":"steakUSDC","heldUsd":0,"targetUsd":33.33,"driftBps":10000,"thresholdBps":500} {"ts":"2026-05-08T14:00:02.600Z","agent":"morpho-yield-optimizer","level":"info","message":"dry_run_skip","label":"approve(0xBEEFA7...)","to":"0x8335..."} {"ts":"2026-05-08T14:00:02.700Z","agent":"morpho-yield-optimizer","level":"info","message":"dry_run_skip","label":"deposit 33.33 -> steakUSDC","to":"0xBEEFA7..."}

The agent found 12 USDC vaults on Base, picked the top 3, and would have deposited equally into each — but since AGENT_DRY_RUN=1, it logged the intended actions without sending transactions.

When you are ready to go live, set AGENT_DRY_RUN=0 in your .env and restart. If any deposit exceeds your daily spend limit, Telegram will prompt you to approve.

What just happened

Your agent ranked every Morpho vault on your chosen chain, built an equal-weight portfolio across the top performers, and planned drift-based rebalancing to keep allocations on target. It also checked for idle funds to sweep to Aave and queried for claimable Morpho rewards. And it did all of this without ever holding your private key — the key stays split between your device and a secure enclave using Two-Party Computation (2PC). When a transaction exceeds your daily spend limit, the agent sends you a Telegram approval request. You approve with one tap.

Going live

Only after the recipe works end-to-end in dry-run mode.

  1. In .env, set AGENT_DRY_RUN=0
  2. Set real vault addresses in WATCHED_VAULTS or leave blank to auto-discover
  3. Set AGENT_MAX_DEPOSIT_USD to your desired cap
  4. Increase daily spend limit: waap-cli policy set --daily-spend-limit 500
  5. Verify 2FA: waap-cli 2fa status
  6. Fund your agent wallet with your target asset + gas token
  7. Deploy with Docker: docker compose up -d

Optional: enable the Aave idle-fallback

Add to your .env:

AAVE_POOL_ADDRESS=0xA238Dd80C259a72e81d7e4664a9801593F98d1c5 AAVE_ATOKEN_ADDRESS=0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB

Find the correct addresses for your chain and asset at docs.aave.com/developers/deployed-contracts .

Optional: enable reward claiming

Add to your .env:

REWARDS_API_URL=https://rewards.morpho.org/v1/users/{address}?chain_id={chainId}

Optional: enable Telegram alerts

Add to your .env:

TG_BOT_TOKEN=123456:ABC-DEF... TG_CHAT_ID=your_chat_id

Create a bot via @BotFather  and get your chat ID by messaging @userinfobot .

Supported chains

ChainChain IDNotes
Ethereum1Highest TVL, higher gas
Base8453Recommended — low gas, growing vault selection
Arbitrum42161Low gas, good vault selection
Optimism10Lower TVL but growing

Morpho’s MetaMorpho vaults use the same ERC-4626 interface across all chains. Find vault addresses at app.morpho.org/vaults .

Next steps

  • Tune the portfolio: Set AGENT_PORTFOLIO_TOP_N=1 for winner-take-all, or increase to 5 for broader diversification
  • Tighten drift: Lower AGENT_REBAL_DRIFT_BPS to 200 (2%) for more frequent rebalancing, or raise to 1000 (10%) for fewer transactions
  • Add AI risk scoring: Wire in an LLM to evaluate vault risk (audit status, curator reputation, TVL trends) before including a vault in the target set
  • Multi-chain: Run separate instances per chain or extend the agent to compare vaults across Ethereum, Base, and Arbitrum
  • Know someone who would want this but does not code? Share the starter
Last updated on