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 setAGENT_DRY_RUN=0after 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
.envfiles and add.envto.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-infoWhat 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 viemCreate 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 to0for 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:
- Ranks all vaults by net APY and picks the top N.
- Calculates the equal-weight target for each leg (
totalPortfolioValue / N). - Exits any held position that dropped out of the top N.
- 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=0x4e65fE4DbA92790696d040ac24Aa414708F5c0ABThe 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=1package.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-cliSee it work
npm install
npm startIn 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.
- In
.env, setAGENT_DRY_RUN=0 - Set real vault addresses in
WATCHED_VAULTSor leave blank to auto-discover - Set
AGENT_MAX_DEPOSIT_USDto your desired cap - Increase daily spend limit:
waap-cli policy set --daily-spend-limit 500 - Verify 2FA:
waap-cli 2fa status - Fund your agent wallet with your target asset + gas token
- Deploy with Docker:
docker compose up -d
Optional: enable the Aave idle-fallback
Add to your .env:
AAVE_POOL_ADDRESS=0xA238Dd80C259a72e81d7e4664a9801593F98d1c5
AAVE_ATOKEN_ADDRESS=0x4e65fE4DbA92790696d040ac24Aa414708F5c0ABFind 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_idCreate a bot via @BotFather and get your chat ID by messaging @userinfobot .
Supported chains
| Chain | Chain ID | Notes |
|---|---|---|
| Ethereum | 1 | Highest TVL, higher gas |
| Base | 8453 | Recommended — low gas, growing vault selection |
| Arbitrum | 42161 | Low gas, good vault selection |
| Optimism | 10 | Lower 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=1for winner-take-all, or increase to 5 for broader diversification - Tighten drift: Lower
AGENT_REBAL_DRIFT_BPSto 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
Related
- Morpho Yield Optimizer Starter — config-only quickstart, 2 minutes
- Yield with Aave — SDK recipe for Aave V3 lending
- Safety & Architecture