Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,7 @@ async function main() {
"get_morpho_positions",
{
description:
"Fetch Morpho Blue positions for a wallet across a given list of market IDs. Each market is identified by a bytes32 id (keccak256 of its MarketParams). Returns per-market supplied/borrowed assets and collateral.",
"Fetch Morpho Blue positions for a wallet. If `marketIds` is omitted, the server auto-discovers the wallet's markets by scanning Morpho Blue event logs (may take several seconds on a cold call). Pass explicit `marketIds` (bytes32 each, keccak256 of MarketParams) as a fast path. Returns per-market supplied/borrowed assets and collateral.",
inputSchema: getMorphoPositionsInput.shape,
},
handler(getMorphoPositions)
Expand Down
107 changes: 107 additions & 0 deletions src/modules/morpho/discover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { parseAbiItem } from "viem";
import { getClient } from "../../data/rpc.js";
import { CONTRACTS } from "../../config/contracts.js";
import type { SupportedChain } from "../../types/index.js";

/**
* Morpho Blue deployment block per chain. We only start the log scan from
* here — scanning back to genesis is a multi-million-block waste on mainnet.
*/
const MORPHO_DEPLOYMENT_BLOCK: Partial<Record<SupportedChain, bigint>> = {
ethereum: 18883124n,
};

/**
* Most public RPC providers (Alchemy/Infura free tier, public nodes) cap
* `eth_getLogs` at 10k blocks per request. Users on premium endpoints with
* higher caps can override via MORPHO_DISCOVERY_CHUNK.
*/
const SCAN_CHUNK: bigint = (() => {
const raw = process.env.MORPHO_DISCOVERY_CHUNK;
if (!raw) return 10_000n;
const parsed = BigInt(raw);
return parsed > 0n ? parsed : 10_000n;
})();

/**
* Position-opening events: a wallet's set of active markets is a subset of
* the markets it has ever opened into. Closed positions drop out later when
* readMarketPosition sees zero shares/collateral. Withdraw/Repay/Liquidate
* never introduce a fresh market, so we don't scan them.
*
* In Morpho Blue, `onBehalf` is indexed on all three, which means the RPC
* does the filter server-side via topic3 (or topic2 for Borrow — viem maps
* named args to the correct topic slot automatically).
*/
const supplyEvent = parseAbiItem(
"event Supply(bytes32 indexed id, address indexed caller, address indexed onBehalf, uint256 assets, uint256 shares)"
);
const borrowEvent = parseAbiItem(
"event Borrow(bytes32 indexed id, address caller, address indexed onBehalf, address indexed receiver, uint256 assets, uint256 shares)"
);
const supplyCollateralEvent = parseAbiItem(
"event SupplyCollateral(bytes32 indexed id, address indexed caller, address indexed onBehalf, uint256 assets)"
);

function morphoAddress(chain: SupportedChain): `0x${string}` | null {
const entry = (CONTRACTS as Record<string, Record<string, Record<string, string>>>)[chain]
?.morpho;
const addr = entry?.blue;
return (addr as `0x${string}` | undefined) ?? null;
}

/**
* Discover every Morpho Blue marketId the wallet has ever opened a position
* in (as `onBehalf`) on a single chain. Returns unique ids; callers should
* treat these as candidates and re-read live state via `readMarketPosition`
* to filter out closed positions.
*
* Returns `[]` for chains with no Morpho Blue deployment.
*/
export async function discoverMorphoMarketIds(
wallet: `0x${string}`,
chain: SupportedChain
): Promise<`0x${string}`[]> {
const morpho = morphoAddress(chain);
const deploymentBlock = MORPHO_DEPLOYMENT_BLOCK[chain];
if (!morpho || deploymentBlock === undefined) return [];

const client = getClient(chain);
const latest = await client.getBlockNumber();

const ids = new Set<`0x${string}`>();

for (let from = deploymentBlock; from <= latest; from += SCAN_CHUNK) {
const to = from + SCAN_CHUNK - 1n > latest ? latest : from + SCAN_CHUNK - 1n;
const [supplyLogs, borrowLogs, collateralLogs] = await Promise.all([
client.getLogs({
address: morpho,
event: supplyEvent,
args: { onBehalf: wallet },
fromBlock: from,
toBlock: to,
}),
client.getLogs({
address: morpho,
event: borrowEvent,
args: { onBehalf: wallet },
fromBlock: from,
toBlock: to,
}),
client.getLogs({
address: morpho,
event: supplyCollateralEvent,
args: { onBehalf: wallet },
fromBlock: from,
toBlock: to,
}),
]);

for (const log of [...supplyLogs, ...borrowLogs, ...collateralLogs]) {
const id = (log.args as { id?: `0x${string}` }).id;
if (id) ids.add(id);
}
}

return Array.from(ids);
}
7 changes: 6 additions & 1 deletion src/modules/morpho/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CONTRACTS } from "../../config/contracts.js";
import { morphoBlueAbi } from "../../abis/morpho-blue.js";
import { erc20Abi } from "../../abis/erc20.js";
import { makeTokenAmount, priceTokenAmounts, round } from "../../data/format.js";
import { discoverMorphoMarketIds } from "./discover.js";
import type { GetMorphoPositionsArgs } from "./schemas.js";
import type { SupportedChain, TokenAmount } from "../../types/index.js";

Expand Down Expand Up @@ -141,7 +142,11 @@ export async function getMorphoPositions(
if (!morpho) {
return { wallet, positions: [] };
}
const marketIds = args.marketIds as `0x${string}`[];
const marketIds = (args.marketIds as `0x${string}`[] | undefined) ??
(await discoverMorphoMarketIds(wallet, chain));
if (marketIds.length === 0) {
return { wallet, positions: [] };
}
const results = await Promise.all(
marketIds.map((id) => readMarketPosition(wallet, chain, morpho, id))
);
Expand Down
11 changes: 9 additions & 2 deletions src/modules/morpho/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@ const marketIdSchema = z.string().regex(/^0x[a-fA-F0-9]{64}$/);
export const getMorphoPositionsInput = z.object({
wallet: walletSchema,
chain: chainEnum.default("ethereum"),
/** Morpho Blue market IDs (bytes32 each) to check. Discover via the Morpho app or subgraph. */
marketIds: z.array(marketIdSchema).min(1),
/**
* Morpho Blue market IDs (bytes32 each) to check. If omitted, the server
* discovers the wallet's markets by scanning Morpho Blue event logs
* (Supply / Borrow / SupplyCollateral with `onBehalf == wallet`). Pass this
* explicitly as a fast path when the set of markets is already known —
* discovery on cold lookups walks from Morpho's deploy block to head in
* ~10k-block chunks and can take several seconds.
*/
marketIds: z.array(marketIdSchema).optional(),
});

const baseMarketAction = z.object({
Expand Down
85 changes: 47 additions & 38 deletions src/modules/portfolio/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getTokenPrice } from "../../data/prices.js";
import { getLendingPositions, getLpPositions } from "../positions/index.js";
import { getStakingPositions } from "../staking/index.js";
import { getCompoundPositions } from "../compound/index.js";
import { getMorphoPositions } from "../morpho/index.js";
import type { GetPortfolioSummaryArgs } from "./schemas.js";
import type {
LendingPositionUnion,
Expand Down Expand Up @@ -112,10 +113,7 @@ export async function getPortfolioSummary(
const mergedCoverage: PortfolioCoverage = {
aave: mergeCoverage(perWallet.map((p) => p.coverage.aave)),
compound: mergeCoverage(perWallet.map((p) => p.coverage.compound)),
morpho: perWallet[0]?.coverage.morpho ?? {
covered: false,
note: "Morpho Blue requires caller-supplied marketIds.",
},
morpho: mergeCoverage(perWallet.map((p) => p.coverage.morpho)),
uniswapV3: mergeCoverage(perWallet.map((p) => p.coverage.uniswapV3)),
staking: mergeCoverage(perWallet.map((p) => p.coverage.staking)),
unpricedAssets: perWallet.reduce((s, p) => s + p.coverage.unpricedAssets, 0),
Expand Down Expand Up @@ -152,48 +150,62 @@ async function buildWalletSummary(
// Each subquery is independent — one failing shouldn't kill the summary. We swap
// Promise.all for per-task catchers that return empty payloads on error, so a flaky
// Aave read (say, "returned no data") still lets us report native + ERC-20 + LP totals.
// Morpho Blue is deliberately NOT included here: it requires caller-supplied marketIds
// (Blue has no on-chain enumeration of a user's markets). Surface Morpho via the
// dedicated get_morpho_positions tool instead.
// Morpho Blue is discovered per-chain via event-log scan (onBehalf==wallet) since
// Blue has no on-chain enumeration; discovery is the slowest subquery here on a
// cold RPC, so it's wrapped in the same catch-and-continue pattern as the others.
const emptyPositions = { wallet, positions: [] as never[] };
// Wrap each subquery so the portfolio can distinguish failure from empty. A
// thrown fetch becomes errored:true so callers don't mistake "Aave down" for
// "no Aave position".
const errors = { aave: false, compound: false, lp: false, staking: false };
const [nativeAmounts, erc20Amounts, aave, compound, lp, staking] = await Promise.all([
Promise.all(
chains.map((c) =>
fetchNativeBalance(wallet, c).catch(() => zeroNative(wallet, c))
)
),
Promise.all(chains.map((c) => fetchTopErc20Balances(wallet, c).catch(() => []))),
getLendingPositions({ wallet, chains }).catch(() => {
errors.aave = true;
return emptyPositions as never;
}),
getCompoundPositions({ wallet, chains }).catch(() => {
errors.compound = true;
return emptyPositions as never;
}),
getLpPositions({ wallet, chains }).catch(() => {
errors.lp = true;
return emptyPositions as never;
}),
getStakingPositions({ wallet, chains }).catch(() => {
errors.staking = true;
return emptyPositions as never;
}),
]);
const errors = { aave: false, compound: false, morpho: false, lp: false, staking: false };
const [nativeAmounts, erc20Amounts, aave, compound, morphoByChain, lp, staking] =
await Promise.all([
Promise.all(
chains.map((c) =>
fetchNativeBalance(wallet, c).catch(() => zeroNative(wallet, c))
)
),
Promise.all(chains.map((c) => fetchTopErc20Balances(wallet, c).catch(() => []))),
getLendingPositions({ wallet, chains }).catch(() => {
errors.aave = true;
return emptyPositions as never;
}),
getCompoundPositions({ wallet, chains }).catch(() => {
errors.compound = true;
return emptyPositions as never;
}),
// Morpho has no multi-chain list endpoint; fan out per-chain and swallow
// per-chain failures individually so one bad RPC doesn't drop the whole
// Morpho bucket. If any chain throws, the overall coverage is errored.
Promise.all(
chains.map((c) =>
getMorphoPositions({ wallet, chain: c }).catch(() => {
errors.morpho = true;
return { wallet, positions: [] };
})
)
),
getLpPositions({ wallet, chains }).catch(() => {
errors.lp = true;
return emptyPositions as never;
}),
getStakingPositions({ wallet, chains }).catch(() => {
errors.staking = true;
return emptyPositions as never;
}),
]);
const morphoPositions = morphoByChain.flatMap((r) => r.positions);

// Filter zero native balances out.
const native = nativeAmounts.filter((a) => a.amount !== "0");
const erc20 = erc20Amounts.flat();

// Merge Aave + Compound into a single lending bucket — they both carry `chain` and
// `netValueUsd`, which is all the summary math needs.
// Merge Aave + Compound + Morpho into a single lending bucket — they all carry
// `chain` and `netValueUsd`, which is all the summary math needs.
const lendingPositions: LendingPositionUnion[] = [
...aave.positions,
...compound.positions,
...morphoPositions,
];

const walletBalancesUsd = round(
Expand Down Expand Up @@ -229,10 +241,7 @@ async function buildWalletSummary(
const coverage: PortfolioCoverage = {
aave: { covered: !errors.aave, ...(errors.aave ? { errored: true, note: "Aave fetch failed — positions not included in totals." } : {}) },
compound: { covered: !errors.compound, ...(errors.compound ? { errored: true, note: "Compound V3 fetch failed — positions not included in totals." } : {}) },
morpho: {
covered: false,
note: "Morpho Blue positions require caller-supplied marketIds (no on-chain enumeration from a wallet). Call get_morpho_positions separately if the user has Morpho exposure.",
},
morpho: { covered: !errors.morpho, ...(errors.morpho ? { errored: true, note: "Morpho Blue event-log discovery failed on at least one chain — some positions may be missing from totals." } : {}) },
uniswapV3: { covered: !errors.lp, ...(errors.lp ? { errored: true, note: "Uniswap V3 LP fetch failed — positions not included." } : {}) },
staking: { covered: !errors.staking, ...(errors.staking ? { errored: true, note: "Staking (Lido/EigenLayer) fetch failed — positions not included." } : {}) },
unpricedAssets,
Expand Down
26 changes: 25 additions & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,32 @@ export interface CompoundLendingPosition {
netValueUsd: number;
}

/**
* A Morpho Blue position, flattened enough to slot alongside Aave and Compound in a
* unified lending bucket. Thin projection of modules/morpho/index.ts#MorphoPosition
* so the types module doesn't need to pull in morpho internals.
*/
export interface MorphoLendingPosition {
protocol: "morpho-blue";
chain: SupportedChain;
marketId: `0x${string}`;
loanToken: `0x${string}`;
collateralToken: `0x${string}`;
lltv: string;
supplied: TokenAmount | null;
borrowed: TokenAmount | null;
collateral: TokenAmount | null;
totalCollateralUsd: number;
totalDebtUsd: number;
totalSuppliedUsd: number;
netValueUsd: number;
}

/** Any lending/borrowing position reported by the portfolio aggregator. */
export type LendingPositionUnion = LendingPosition | CompoundLendingPosition;
export type LendingPositionUnion =
| LendingPosition
| CompoundLendingPosition
| MorphoLendingPosition;

export interface LPPosition {
protocol: "uniswap-v3";
Expand Down
Loading