From e2ba642f260ff3369ebc6c9bef91800e4bc6d41e Mon Sep 17 00:00:00 2001 From: Hui-Sang Kim <102507786+Hiksang@users.noreply.github.com> Date: Thu, 7 May 2026 16:50:22 +0900 Subject: [PATCH] feat(morpho): wire marketId-based supply/borrow + supplyCollateral MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-fix: every direct Morpho Blue tx (supply/borrow/repay/withdraw) called `defaultMarketParams(asset)` which returned all-zero MarketParams. This stub silently sent garbage on-chain — every broadcast reverted because the (loanToken=0, collateralToken=0, oracle=0, irm=0, lltv=0) tuple does not match any registered market. The MetaMorpho-vault path (Felix on HyperEVM) worked, but the underlying Morpho Blue market path was 100% broken. Reference: https://docs.morpho.org/get-started/resources/contracts/morpho/ The Morpho API confirms many real markets exist on Monad (143) and HyperEVM (999) — e.g. Monad's WMON/AUSD market at marketId 0xfa0b720389b546fcf8562c18cda8c00460072b63776add7fbfe8cd4f06d7c3ba (loan=AUSD, collateral=WMON, lltv=77%). The user just needs a way to point the adapter at a known marketId. Fix: 1. defi-core/types.ts: - Add MorphoMarketId = `0x${string}` for clarity. - Add optional `market_id?` to SupplyParams / BorrowParams / RepayParams / WithdrawParams. Aave V3 / Compound adapters ignore it; the Morpho adapter dispatches on it. - Add SupplyCollateralParams / WithdrawCollateralParams (market_id required) for the Morpho-only collateral side. 2. defi-core/traits/lending.ts: - Optional `buildSupplyCollateral?` / `buildWithdrawCollateral?` (Aave V3 collapses both into supply/withdraw, so it leaves them undefined; Morpho Blue requires the dedicated selectors.) 3. defi-protocols/lending/morpho.ts: - New private `resolveMarketParams(marketId)` — reads Morpho.idToMarketParams(id) over the configured RPC and returns the 5-field tuple. Throws DefiError if the result is empty (zero-init guard) so users see a clear error instead of an opaque on-chain revert when they pass a marketId the deployment doesn't know about. - buildSupply: when `market_id` is set, route to direct Morpho.supply with the resolved tuple + approvals[]. Without market_id and without a registered MetaMorpho vault, throw instead of falling through to the old zero-stub. - buildBorrow / buildRepay: require market_id (Morpho Blue has no non-market borrow surface). Approvals[] for repay only. - buildWithdraw: prefer market_id if set, otherwise the MetaMorpho-vault branch, otherwise throw. - New buildSupplyCollateral / buildWithdrawCollateral methods using the dedicated Morpho selectors. - ABI gains supplyCollateral + withdrawCollateral entries. - Removed the dead `defaultMarketParams` helper. 4. defi-cli/commands/lending.ts: - All four state-changing commands gain `--market ` (passed through as `market_id` to the adapter). Aave V3 / Compound users can ignore it. - New CLI commands `lending supply-collateral` and `lending withdraw-collateral`. Each requires `--market` and emits a clear error envelope when the resolved adapter doesn't implement the corresponding optional method (e.g. Aave V3). Test plan: - 8 new unit tests in morpho.test.ts: * supply/borrow/repay/withdraw with marketId — calldata decode against the resolved AUSD/WMON/oracle/irm/lltv tuple. * supplyCollateral / withdrawCollateral happy paths. * borrow without marketId → DefiError("marketId"). * idToMarketParams returning zero-init → DefiError("empty MarketParams"). - vi.mock(viem) so createPublicClient.readContract returns the canonical AUSD/WMON tuple; tests run offline. Verified: - pnpm -C ts -r build — clean. - pnpm -C ts -r lint — 3 packages, tsc --noEmit clean. - pnpm -C ts -r test — defi-core 32/32, defi-protocols 55/55 (was 47; +8), defi-cli 102/102. Out of scope: - Live mainnet borrow lifecycle on Monad (requires WMON wrap from native MON first; the `defi token` surface doesn't expose WMON.deposit() yet). - Per-chain marketIds TOML registry (so users don't have to look them up). Tracked as a follow-up. - Compound V2 enterMarkets (Venus) — separate adapter limitation. --- ts/packages/defi-cli/src/commands/lending.ts | 81 +++++- ts/packages/defi-core/src/traits/lending.ts | 16 ++ ts/packages/defi-core/src/types.ts | 35 +++ .../defi-protocols/src/lending/morpho.test.ts | 243 ++++++++++++++++++ .../defi-protocols/src/lending/morpho.ts | 170 +++++++++--- 5 files changed, 504 insertions(+), 41 deletions(-) create mode 100644 ts/packages/defi-protocols/src/lending/morpho.test.ts diff --git a/ts/packages/defi-cli/src/commands/lending.ts b/ts/packages/defi-cli/src/commands/lending.ts index da446e9..e98637d 100644 --- a/ts/packages/defi-cli/src/commands/lending.ts +++ b/ts/packages/defi-cli/src/commands/lending.ts @@ -2,7 +2,7 @@ import type { Command } from "commander"; import type { OutputMode } from "../output.js"; import type { Executor } from "../executor.js"; import { printOutput } from "../output.js"; -import { InterestRateMode } from "@hypurrquant/defi-core"; +import { InterestRateMode, type MorphoMarketId } from "@hypurrquant/defi-core"; import type { Address } from "viem"; import { maxUint256 } from "viem"; import { createLending } from "@hypurrquant/defi-protocols"; @@ -59,6 +59,7 @@ export function registerLending(parent: Command, getOpts: () => OutputMode, make .requiredOption("--protocol ", "Protocol slug") .requiredOption("--asset ", "Token symbol or address") .requiredOption("--amount ", "Amount to supply in wei (or 'max')") + .option("--market ", "Morpho Blue marketId (32-byte hex) — required for direct Morpho markets, ignored elsewhere") .option("--on-behalf-of
", "On behalf of address") .action(async (opts) => { const executor = makeExecutor(); @@ -67,7 +68,10 @@ export function registerLending(parent: Command, getOpts: () => OutputMode, make const adapter = createLending(ctx.protocol!, ctx.rpcUrl); const asset = resolveTokenAddress(ctx.registry, ctx.chainName, opts.asset); const onBehalfOf = resolveWallet(opts.onBehalfOf); - const tx = await adapter.buildSupply({ protocol: ctx.protocol!.name, asset, amount: parseAmount(opts.amount), on_behalf_of: onBehalfOf }); + const tx = await adapter.buildSupply({ + protocol: ctx.protocol!.name, asset, amount: parseAmount(opts.amount), on_behalf_of: onBehalfOf, + market_id: opts.market as MorphoMarketId | undefined, + }); const result = await executor.execute(tx); printOutput(result, getOpts()); }); @@ -78,6 +82,7 @@ export function registerLending(parent: Command, getOpts: () => OutputMode, make .requiredOption("--asset ", "Token symbol or address") .requiredOption("--amount ", "Amount in wei (or 'max')") .option("--rate-mode ", "variable or stable", "variable") + .option("--market ", "Morpho Blue marketId (32-byte hex) — required for direct Morpho markets, ignored elsewhere") .option("--on-behalf-of
", "On behalf of address") .action(async (opts) => { const executor = makeExecutor(); @@ -90,6 +95,7 @@ export function registerLending(parent: Command, getOpts: () => OutputMode, make protocol: ctx.protocol!.name, asset, amount: parseAmount(opts.amount), interest_rate_mode: opts.rateMode === "stable" ? InterestRateMode.Stable : InterestRateMode.Variable, on_behalf_of: onBehalfOf, + market_id: opts.market as MorphoMarketId | undefined, }); const result = await executor.execute(tx); printOutput(result, getOpts()); @@ -101,6 +107,7 @@ export function registerLending(parent: Command, getOpts: () => OutputMode, make .requiredOption("--asset ", "Token symbol or address") .requiredOption("--amount ", "Amount in wei (or 'max')") .option("--rate-mode ", "variable or stable", "variable") + .option("--market ", "Morpho Blue marketId (32-byte hex) — required for direct Morpho markets, ignored elsewhere") .option("--on-behalf-of
", "On behalf of address") .action(async (opts) => { const executor = makeExecutor(); @@ -113,6 +120,7 @@ export function registerLending(parent: Command, getOpts: () => OutputMode, make protocol: ctx.protocol!.name, asset, amount: parseAmount(opts.amount), interest_rate_mode: opts.rateMode === "stable" ? InterestRateMode.Stable : InterestRateMode.Variable, on_behalf_of: onBehalfOf, + market_id: opts.market as MorphoMarketId | undefined, }); const result = await executor.execute(tx); printOutput(result, getOpts()); @@ -123,6 +131,7 @@ export function registerLending(parent: Command, getOpts: () => OutputMode, make .requiredOption("--protocol ", "Protocol slug") .requiredOption("--asset ", "Token symbol or address") .requiredOption("--amount ", "Amount in wei (or 'max')") + .option("--market ", "Morpho Blue marketId (32-byte hex) — required for direct Morpho markets, ignored elsewhere") .option("--to
", "Recipient address") .action(async (opts) => { const executor = makeExecutor(); @@ -131,7 +140,10 @@ export function registerLending(parent: Command, getOpts: () => OutputMode, make const adapter = createLending(ctx.protocol!, ctx.rpcUrl); const asset = resolveTokenAddress(ctx.registry, ctx.chainName, opts.asset); const to = resolveWallet(opts.to); - const tx = await adapter.buildWithdraw({ protocol: ctx.protocol!.name, asset, amount: parseAmount(opts.amount), to }); + const tx = await adapter.buildWithdraw({ + protocol: ctx.protocol!.name, asset, amount: parseAmount(opts.amount), to, + market_id: opts.market as MorphoMarketId | undefined, + }); const result = await executor.execute(tx); printOutput(result, getOpts()); }); @@ -192,4 +204,67 @@ export function registerLending(parent: Command, getOpts: () => OutputMode, make const result = await executor.execute(tx); printOutput(result, getOpts()); }); + + lending.command("supply-collateral") + .description("Supply the collateral side of a Morpho Blue market (different selector from supply)") + .requiredOption("--protocol ", "Protocol slug (must be a Morpho Blue adapter)") + .requiredOption("--asset ", "Collateral token symbol or address") + .requiredOption("--amount ", "Amount in wei (or 'max')") + .requiredOption("--market ", "32-byte Morpho marketId (find via Morpho API)") + .option("--on-behalf-of
", "On behalf of address") + .action(async (opts) => { + const executor = makeExecutor(); + const ctx = resolveContext(parent, getOpts, opts.protocol); + if (!ctx) return; + const adapter = createLending(ctx.protocol!, ctx.rpcUrl); + if (typeof adapter.buildSupplyCollateral !== "function") { + printOutput({ + error: `[${ctx.protocol!.name}] adapter does not implement buildSupplyCollateral. ` + + `Only Morpho Blue forks expose this; Aave V3 / Compound use plain supply.`, + }, getOpts()); + return; + } + const asset = resolveTokenAddress(ctx.registry, ctx.chainName, opts.asset); + const onBehalfOf = resolveWallet(opts.onBehalfOf); + const tx = await adapter.buildSupplyCollateral({ + protocol: ctx.protocol!.name, + asset, + amount: parseAmount(opts.amount), + on_behalf_of: onBehalfOf, + market_id: opts.market as MorphoMarketId, + }); + const result = await executor.execute(tx); + printOutput(result, getOpts()); + }); + + lending.command("withdraw-collateral") + .description("Withdraw the collateral side of a Morpho Blue market") + .requiredOption("--protocol ", "Protocol slug (must be a Morpho Blue adapter)") + .requiredOption("--asset ", "Collateral token symbol or address") + .requiredOption("--amount ", "Amount in wei (or 'max')") + .requiredOption("--market ", "32-byte Morpho marketId") + .option("--to
", "Recipient address") + .action(async (opts) => { + const executor = makeExecutor(); + const ctx = resolveContext(parent, getOpts, opts.protocol); + if (!ctx) return; + const adapter = createLending(ctx.protocol!, ctx.rpcUrl); + if (typeof adapter.buildWithdrawCollateral !== "function") { + printOutput({ + error: `[${ctx.protocol!.name}] adapter does not implement buildWithdrawCollateral.`, + }, getOpts()); + return; + } + const asset = resolveTokenAddress(ctx.registry, ctx.chainName, opts.asset); + const to = resolveWallet(opts.to); + const tx = await adapter.buildWithdrawCollateral({ + protocol: ctx.protocol!.name, + asset, + amount: parseAmount(opts.amount), + to, + market_id: opts.market as MorphoMarketId, + }); + const result = await executor.execute(tx); + printOutput(result, getOpts()); + }); } diff --git a/ts/packages/defi-core/src/traits/lending.ts b/ts/packages/defi-core/src/traits/lending.ts index 4b51bad..37bc312 100644 --- a/ts/packages/defi-core/src/traits/lending.ts +++ b/ts/packages/defi-core/src/traits/lending.ts @@ -4,6 +4,8 @@ import type { BorrowParams, RepayParams, WithdrawParams, + SupplyCollateralParams, + WithdrawCollateralParams, LendingRates, UserPosition, DeFiTx, @@ -35,4 +37,18 @@ export interface ILending { * eMode concept leave this undefined. */ buildSetEMode?(categoryId: number): Promise; + + /** + * Optional — supply the *collateral* side of a Morpho Blue market + * (separate selector from `supply`, which is the loan-asset LP path). + * Aave V3 collapses both into supply/withdraw, so its adapter leaves + * this undefined. Morpho Blue's adapter requires `params.market_id`. + */ + buildSupplyCollateral?(params: SupplyCollateralParams): Promise; + + /** + * Optional — withdraw the collateral side of a Morpho Blue market. + * Aave V3 leaves this undefined; Morpho Blue requires market_id. + */ + buildWithdrawCollateral?(params: WithdrawCollateralParams): Promise; } diff --git a/ts/packages/defi-core/src/types.ts b/ts/packages/defi-core/src/types.ts index f7cb683..9a99410 100644 --- a/ts/packages/defi-core/src/types.ts +++ b/ts/packages/defi-core/src/types.ts @@ -170,11 +170,21 @@ export interface RemoveLiquidityParams { // === Lending Types === +/** + * Optional 32-byte Morpho Blue marketId. When provided, Morpho-style + * adapters resolve the full MarketParams via `idToMarketParams(id)` + * instead of falling back to a stub. Aave V3 / Compound V2 / Compound V3 + * adapters ignore this field — they identify positions by reserve + * address alone. + */ +export type MorphoMarketId = `0x${string}`; + export interface SupplyParams { protocol: string; asset: Address; amount: bigint; on_behalf_of: Address; + market_id?: MorphoMarketId; } export interface BorrowParams { @@ -183,6 +193,7 @@ export interface BorrowParams { amount: bigint; interest_rate_mode: InterestRateMode; on_behalf_of: Address; + market_id?: MorphoMarketId; } /** Interest rate mode (serde: snake_case) */ @@ -197,6 +208,7 @@ export interface RepayParams { amount: bigint; interest_rate_mode: InterestRateMode; on_behalf_of: Address; + market_id?: MorphoMarketId; } export interface WithdrawParams { @@ -204,6 +216,29 @@ export interface WithdrawParams { asset: Address; amount: bigint; to: Address; + market_id?: MorphoMarketId; +} + +/** + * Morpho Blue distinguishes loan-side liquidity (supply / withdraw) from + * collateral-side liquidity (supplyCollateral / withdrawCollateral). + * Aave V3 collapses both into supply/withdraw; Morpho needs the dedicated + * params type because the underlying selector and accounting differ. + */ +export interface SupplyCollateralParams { + protocol: string; + asset: Address; + amount: bigint; + on_behalf_of: Address; + market_id: MorphoMarketId; +} + +export interface WithdrawCollateralParams { + protocol: string; + asset: Address; + amount: bigint; + to: Address; + market_id: MorphoMarketId; } export interface LendingRates { diff --git a/ts/packages/defi-protocols/src/lending/morpho.test.ts b/ts/packages/defi-protocols/src/lending/morpho.test.ts new file mode 100644 index 0000000..a148494 --- /dev/null +++ b/ts/packages/defi-protocols/src/lending/morpho.test.ts @@ -0,0 +1,243 @@ +/** + * Morpho Blue adapter regression tests — verifies that supply/borrow/ + * repay/withdraw plus the new supplyCollateral/withdrawCollateral + * methods all encode the right calldata when a marketId is pinned. + * + * Pre-2026-05-07 the adapter shipped a `defaultMarketParams(asset)` + * stub that returned all-zero MarketParams; every direct-Morpho-Blue + * tx therefore reverted on-chain. This file pins the post-fix contract: + * caller passes a 32-byte marketId, the adapter dynamically resolves + * MarketParams via `Morpho.idToMarketParams(id)`, and the calldata + * matches the resolved tuple. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { decodeFunctionData, parseAbi } from "viem"; +import type { Address, Hex } from "viem"; +import { ProtocolCategory, InterestRateMode, type ProtocolEntry } from "@hypurrquant/defi-core"; + +const MORPHO = "0x6c247b1F6182318877311737BaC0844bAa518F5e" as Address; +const LOAN_TOKEN = "0x00000000eFE302BEAA2b3e6e1b18d08D69a9012a" as Address; // AUSD +const COLLAT_TOKEN = "0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A" as Address; // WMON +const ORACLE = "0x409b68a5986a84fD90761BAEb34cB242a2ee02eF" as Address; +const IRM = "0x09475a3D6eA8c314c592b1a3799bDE044E2F400F" as Address; +const LLTV = 770000000000000000n; // 77% +const MARKET_ID = "0xfa0b720389b546fcf8562c18cda8c00460072b63776add7fbfe8cd4f06d7c3ba" as `0x${string}`; +const ON_BEHALF = "0x000000000000000000000000000000000000dEaD" as Address; + +function makeEntry(): ProtocolEntry { + return { + name: "Morpho Blue Monad", + slug: "morpho-blue-monad", + category: ProtocolCategory.Lending, + interface: "morpho_blue", + chain: "monad", + contracts: { morpho_blue: MORPHO }, + } as ProtocolEntry; +} + +const readContractMock = vi.fn(); + +vi.mock("viem", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createPublicClient: () => ({ readContract: readContractMock }), + http: () => () => ({}), + }; +}); + +const SUPPLY_ABI = parseAbi([ + "function supply((address loanToken, address collateralToken, address oracle, address irm, uint256 lltv) marketParams, uint256 assets, uint256 shares, address onBehalf, bytes data)", +]); +const BORROW_ABI = parseAbi([ + "function borrow((address loanToken, address collateralToken, address oracle, address irm, uint256 lltv) marketParams, uint256 assets, uint256 shares, address onBehalf, address receiver)", +]); +const REPAY_ABI = parseAbi([ + "function repay((address loanToken, address collateralToken, address oracle, address irm, uint256 lltv) marketParams, uint256 assets, uint256 shares, address onBehalf, bytes data)", +]); +const WITHDRAW_ABI = parseAbi([ + "function withdraw((address loanToken, address collateralToken, address oracle, address irm, uint256 lltv) marketParams, uint256 assets, uint256 shares, address onBehalf, address receiver)", +]); +const SUPPLY_COLLATERAL_ABI = parseAbi([ + "function supplyCollateral((address loanToken, address collateralToken, address oracle, address irm, uint256 lltv) marketParams, uint256 assets, address onBehalf, bytes data)", +]); +const WITHDRAW_COLLATERAL_ABI = parseAbi([ + "function withdrawCollateral((address loanToken, address collateralToken, address oracle, address irm, uint256 lltv) marketParams, uint256 assets, address onBehalf, address receiver)", +]); + +function mockMarketParamsResolution(): void { + // idToMarketParams returns the canonical AUSD/WMON tuple + readContractMock.mockResolvedValueOnce([LOAN_TOKEN, COLLAT_TOKEN, ORACLE, IRM, LLTV]); +} + +interface MarketTuple { + loanToken: Address; + collateralToken: Address; + oracle: Address; + irm: Address; + lltv: bigint; +} + +function expectMarketParamsMatch(tuple: MarketTuple): void { + expect(tuple.loanToken).toBe(LOAN_TOKEN); + expect(tuple.collateralToken).toBe(COLLAT_TOKEN); + expect(tuple.oracle).toBe(ORACLE); + expect(tuple.irm).toBe(IRM); + expect(tuple.lltv).toBe(LLTV); +} + +describe("MorphoBlueAdapter — direct-market path with marketId", () => { + beforeEach(() => readContractMock.mockReset()); + + it("buildSupply with marketId resolves MarketParams and encodes loan-side supply", async () => { + mockMarketParamsResolution(); + const { MorphoBlueAdapter } = await import("./morpho.js"); + const adapter = new MorphoBlueAdapter(makeEntry(), "https://example/monad"); + const tx = await adapter.buildSupply({ + protocol: "Morpho Blue Monad", + asset: LOAN_TOKEN, + amount: 1_000_000n, + on_behalf_of: ON_BEHALF, + market_id: MARKET_ID, + }); + expect(tx.to).toBe(MORPHO); + const decoded = decodeFunctionData({ abi: SUPPLY_ABI, data: tx.data as Hex }); + expectMarketParamsMatch(decoded.args[0] as MarketTuple); + expect(decoded.args[1]).toBe(1_000_000n); // assets + expect(decoded.args[2]).toBe(0n); // shares (0 means amount-based) + expect(decoded.args[3]).toBe(ON_BEHALF); + expect(tx.approvals).toEqual([{ token: LOAN_TOKEN, spender: MORPHO, amount: 1_000_000n }]); + }); + + it("buildBorrow with marketId encodes a Pool.borrow call against the resolved tuple", async () => { + mockMarketParamsResolution(); + const { MorphoBlueAdapter } = await import("./morpho.js"); + const adapter = new MorphoBlueAdapter(makeEntry(), "https://example/monad"); + const tx = await adapter.buildBorrow({ + protocol: "Morpho Blue Monad", + asset: LOAN_TOKEN, + amount: 50_000n, + interest_rate_mode: InterestRateMode.Variable, + on_behalf_of: ON_BEHALF, + market_id: MARKET_ID, + }); + expect(tx.to).toBe(MORPHO); + const decoded = decodeFunctionData({ abi: BORROW_ABI, data: tx.data as Hex }); + expectMarketParamsMatch(decoded.args[0] as MarketTuple); + expect(decoded.args[1]).toBe(50_000n); + expect(decoded.args[3]).toBe(ON_BEHALF); + expect(decoded.args[4]).toBe(ON_BEHALF); // receiver + }); + + it("buildBorrow without marketId throws a clear DefiError", async () => { + const { MorphoBlueAdapter } = await import("./morpho.js"); + const adapter = new MorphoBlueAdapter(makeEntry(), "https://example/monad"); + await expect( + adapter.buildBorrow({ + protocol: "Morpho Blue Monad", + asset: LOAN_TOKEN, + amount: 1n, + interest_rate_mode: InterestRateMode.Variable, + on_behalf_of: ON_BEHALF, + }), + ).rejects.toThrow(/marketId/); + }); + + it("buildRepay with marketId attaches approvals[]", async () => { + mockMarketParamsResolution(); + const { MorphoBlueAdapter } = await import("./morpho.js"); + const adapter = new MorphoBlueAdapter(makeEntry(), "https://example/monad"); + const tx = await adapter.buildRepay({ + protocol: "Morpho Blue Monad", + asset: LOAN_TOKEN, + amount: 25_000n, + interest_rate_mode: InterestRateMode.Variable, + on_behalf_of: ON_BEHALF, + market_id: MARKET_ID, + }); + const decoded = decodeFunctionData({ abi: REPAY_ABI, data: tx.data as Hex }); + expectMarketParamsMatch(decoded.args[0] as MarketTuple); + expect(tx.approvals).toEqual([{ token: LOAN_TOKEN, spender: MORPHO, amount: 25_000n }]); + }); + + it("buildWithdraw with marketId routes loan-side withdrawal to Morpho.withdraw", async () => { + mockMarketParamsResolution(); + const { MorphoBlueAdapter } = await import("./morpho.js"); + const adapter = new MorphoBlueAdapter(makeEntry(), "https://example/monad"); + const tx = await adapter.buildWithdraw({ + protocol: "Morpho Blue Monad", + asset: LOAN_TOKEN, + amount: 10_000n, + to: ON_BEHALF, + market_id: MARKET_ID, + }); + expect(tx.to).toBe(MORPHO); + const decoded = decodeFunctionData({ abi: WITHDRAW_ABI, data: tx.data as Hex }); + expectMarketParamsMatch(decoded.args[0] as MarketTuple); + expect(decoded.args[3]).toBe(ON_BEHALF); // onBehalf = to + expect(decoded.args[4]).toBe(ON_BEHALF); // receiver = to + }); + + it("buildSupplyCollateral encodes supplyCollateral with the resolved tuple", async () => { + mockMarketParamsResolution(); + const { MorphoBlueAdapter } = await import("./morpho.js"); + const adapter = new MorphoBlueAdapter(makeEntry(), "https://example/monad"); + const tx = await adapter.buildSupplyCollateral!({ + protocol: "Morpho Blue Monad", + asset: COLLAT_TOKEN, + amount: 500_000n, + on_behalf_of: ON_BEHALF, + market_id: MARKET_ID, + }); + expect(tx.to).toBe(MORPHO); + const decoded = decodeFunctionData({ abi: SUPPLY_COLLATERAL_ABI, data: tx.data as Hex }); + expectMarketParamsMatch(decoded.args[0] as MarketTuple); + expect(decoded.args[1]).toBe(500_000n); + expect(decoded.args[2]).toBe(ON_BEHALF); + expect(tx.approvals).toEqual([{ token: COLLAT_TOKEN, spender: MORPHO, amount: 500_000n }]); + }); + + it("buildWithdrawCollateral encodes withdrawCollateral", async () => { + mockMarketParamsResolution(); + const { MorphoBlueAdapter } = await import("./morpho.js"); + const adapter = new MorphoBlueAdapter(makeEntry(), "https://example/monad"); + const tx = await adapter.buildWithdrawCollateral!({ + protocol: "Morpho Blue Monad", + asset: COLLAT_TOKEN, + amount: 500_000n, + to: ON_BEHALF, + market_id: MARKET_ID, + }); + expect(tx.to).toBe(MORPHO); + const decoded = decodeFunctionData({ abi: WITHDRAW_COLLATERAL_ABI, data: tx.data as Hex }); + expectMarketParamsMatch(decoded.args[0] as MarketTuple); + expect(decoded.args[1]).toBe(500_000n); + expect(decoded.args[2]).toBe(ON_BEHALF); + expect(decoded.args[3]).toBe(ON_BEHALF); + }); + + it("rejects when idToMarketParams returns an empty tuple (zero-init guard)", async () => { + // Pre-fix bug class: passing a marketId that the deployment doesn't + // know about caused the adapter to silently send all-zero params and + // revert on-chain. Now we throw a clear error before the user spends + // gas on an unknown market. + readContractMock.mockResolvedValueOnce([ + "0x0000000000000000000000000000000000000000" as Address, + "0x0000000000000000000000000000000000000000" as Address, + "0x0000000000000000000000000000000000000000" as Address, + "0x0000000000000000000000000000000000000000" as Address, + 0n, + ]); + const { MorphoBlueAdapter } = await import("./morpho.js"); + const adapter = new MorphoBlueAdapter(makeEntry(), "https://example/monad"); + await expect( + adapter.buildSupply({ + protocol: "Morpho Blue Monad", + asset: LOAN_TOKEN, + amount: 1n, + on_behalf_of: ON_BEHALF, + market_id: "0x0000000000000000000000000000000000000000000000000000000000000000", + }), + ).rejects.toThrow(/empty MarketParams|registered market/); + }); +}); diff --git a/ts/packages/defi-protocols/src/lending/morpho.ts b/ts/packages/defi-protocols/src/lending/morpho.ts index ab6ac9c..4b6985e 100644 --- a/ts/packages/defi-protocols/src/lending/morpho.ts +++ b/ts/packages/defi-protocols/src/lending/morpho.ts @@ -1,4 +1,4 @@ -import { parseAbi, encodeFunctionData, decodeFunctionResult, zeroAddress } from "viem"; +import { parseAbi, encodeFunctionData, decodeFunctionResult, zeroAddress, createPublicClient, http } from "viem"; import type { Address, Hex } from "viem"; import type { ILending } from "@hypurrquant/defi-core"; import { @@ -10,6 +10,8 @@ import { type BorrowParams, type RepayParams, type WithdrawParams, + type SupplyCollateralParams, + type WithdrawCollateralParams, type LendingRates, type UserPosition, type DeFiTx, @@ -19,9 +21,11 @@ const MORPHO_ABI = parseAbi([ "function market(bytes32 id) external view returns (uint128 totalSupplyAssets, uint128 totalSupplyShares, uint128 totalBorrowAssets, uint128 totalBorrowShares, uint128 lastUpdate, uint128 fee)", "function idToMarketParams(bytes32 id) external view returns (address loanToken, address collateralToken, address oracle, address irm, uint256 lltv)", "function supply((address loanToken, address collateralToken, address oracle, address irm, uint256 lltv) marketParams, uint256 assets, uint256 shares, address onBehalf, bytes data) external returns (uint256 assetsSupplied, uint256 sharesSupplied)", + "function supplyCollateral((address loanToken, address collateralToken, address oracle, address irm, uint256 lltv) marketParams, uint256 assets, address onBehalf, bytes data) external", "function borrow((address loanToken, address collateralToken, address oracle, address irm, uint256 lltv) marketParams, uint256 assets, uint256 shares, address onBehalf, address receiver) external returns (uint256 assetsBorrowed, uint256 sharesBorrowed)", "function repay((address loanToken, address collateralToken, address oracle, address irm, uint256 lltv) marketParams, uint256 assets, uint256 shares, address onBehalf, bytes data) external returns (uint256 assetsRepaid, uint256 sharesRepaid)", "function withdraw((address loanToken, address collateralToken, address oracle, address irm, uint256 lltv) marketParams, uint256 assets, uint256 shares, address onBehalf, address receiver) external returns (uint256 assetsWithdrawn, uint256 sharesWithdrawn)", + "function withdrawCollateral((address loanToken, address collateralToken, address oracle, address irm, uint256 lltv) marketParams, uint256 assets, address onBehalf, address receiver) external", ]); const META_MORPHO_ABI = parseAbi([ @@ -55,16 +59,6 @@ type MarketParams = { lltv: bigint; }; -function defaultMarketParams(loanToken: Address = zeroAddress as Address): MarketParams { - return { - loanToken, - collateralToken: zeroAddress as Address, - oracle: zeroAddress as Address, - irm: zeroAddress as Address, - lltv: 0n, - }; -} - function decodeMarket(data: Hex | null): [bigint, bigint, bigint, bigint, bigint, bigint] | null { if (!data) return null; try { @@ -150,7 +144,62 @@ export class MorphoBlueAdapter implements ILending { return this.protocolName; } + /** + * Resolve a Morpho Blue marketId into the full MarketParams tuple by + * calling Morpho.idToMarketParams(id). Used by every direct-market + * method (supply / borrow / repay / withdraw / supplyCollateral / + * withdrawCollateral) so the caller only has to pass the 32-byte + * marketId — same shape as the Morpho UI / API. + */ + private async resolveMarketParams(marketId: `0x${string}`): Promise { + if (!this.rpcUrl) { + throw DefiError.rpcError( + `[${this.protocolName}] No RPC URL configured — cannot resolve marketId ${marketId}`, + ); + } + const client = createPublicClient({ transport: http(this.rpcUrl) }); + let result: readonly [Address, Address, Address, Address, bigint]; + try { + result = await client.readContract({ + address: this.morpho, + abi: MORPHO_ABI, + functionName: "idToMarketParams", + args: [marketId], + }) as readonly [Address, Address, Address, Address, bigint]; + } catch (e) { + throw DefiError.rpcError( + `[${this.protocolName}] idToMarketParams(${marketId}) failed: ${e}`, + ); + } + const [loanToken, collateralToken, oracle, irm, lltv] = result; + if (loanToken === zeroAddress || collateralToken === zeroAddress || lltv === 0n) { + throw DefiError.invalidParam( + `[${this.protocolName}] marketId ${marketId} resolves to an empty MarketParams ` + + `(loan=${loanToken}, collateral=${collateralToken}, lltv=${lltv}). ` + + `Verify the id matches a registered market on this chain.`, + ); + } + return { loanToken, collateralToken, oracle, irm, lltv }; + } + async buildSupply(params: SupplyParams): Promise { + // Direct Morpho Blue market (loan-side LP) when caller pins marketId. + if (params.market_id) { + const market = await this.resolveMarketParams(params.market_id); + const data = encodeFunctionData({ + abi: MORPHO_ABI, + functionName: "supply", + args: [market, params.amount, 0n, params.on_behalf_of, "0x"], + }); + return { + description: `[${this.protocolName}] Supply ${params.amount} of ${params.asset} to market ${params.market_id.slice(0, 10)}…`, + to: this.morpho, + data, + value: 0n, + gas_estimate: 350_000, + approvals: [{ token: params.asset, spender: this.morpho, amount: params.amount }], + }; + } const vault = await this.resolveVault(params.asset); if (vault) { const data = encodeFunctionData({ @@ -167,46 +216,83 @@ export class MorphoBlueAdapter implements ILending { approvals: [{ token: params.asset, spender: vault, amount: params.amount }], }; } - const market = defaultMarketParams(params.asset); + throw DefiError.invalidParam( + `[${this.protocolName}] supply requires either a registered MetaMorpho vault for ` + + `${params.asset} or an explicit --market . The legacy zero-MarketParams ` + + `stub was removed (it always reverted on-chain).`, + ); + } + + async buildBorrow(params: BorrowParams): Promise { + if (!params.market_id) { + throw DefiError.invalidParam( + `[${this.protocolName}] Morpho Blue borrow requires --market . ` + + `Find one via the Morpho API (https://blue-api.morpho.org/graphql).`, + ); + } + const market = await this.resolveMarketParams(params.market_id); const data = encodeFunctionData({ abi: MORPHO_ABI, - functionName: "supply", + functionName: "borrow", + args: [market, params.amount, 0n, params.on_behalf_of, params.on_behalf_of], + }); + return { + description: `[${this.protocolName}] Borrow ${params.amount} of ${params.asset} from market ${params.market_id.slice(0, 10)}…`, + to: this.morpho, + data, + value: 0n, + gas_estimate: 400_000, + }; + } + + async buildRepay(params: RepayParams): Promise { + if (!params.market_id) { + throw DefiError.invalidParam( + `[${this.protocolName}] Morpho Blue repay requires --market .`, + ); + } + const market = await this.resolveMarketParams(params.market_id); + const data = encodeFunctionData({ + abi: MORPHO_ABI, + functionName: "repay", args: [market, params.amount, 0n, params.on_behalf_of, "0x"], }); return { - description: `[${this.protocolName}] Supply ${params.amount} to Morpho market`, + description: `[${this.protocolName}] Repay ${params.amount} of ${params.asset} to market ${params.market_id.slice(0, 10)}…`, to: this.morpho, data, value: 0n, - gas_estimate: 300_000, + gas_estimate: 350_000, + approvals: [{ token: params.asset, spender: this.morpho, amount: params.amount }], }; } - async buildBorrow(params: BorrowParams): Promise { - const market = defaultMarketParams(params.asset); + async buildSupplyCollateral(params: SupplyCollateralParams): Promise { + const market = await this.resolveMarketParams(params.market_id); const data = encodeFunctionData({ abi: MORPHO_ABI, - functionName: "borrow", - args: [market, params.amount, 0n, params.on_behalf_of, params.on_behalf_of], + functionName: "supplyCollateral", + args: [market, params.amount, params.on_behalf_of, "0x"], }); return { - description: `[${this.protocolName}] Borrow ${params.amount} from Morpho market`, + description: `[${this.protocolName}] Supply collateral ${params.amount} of ${params.asset} to market ${params.market_id.slice(0, 10)}…`, to: this.morpho, data, value: 0n, gas_estimate: 350_000, + approvals: [{ token: params.asset, spender: this.morpho, amount: params.amount }], }; } - async buildRepay(params: RepayParams): Promise { - const market = defaultMarketParams(params.asset); + async buildWithdrawCollateral(params: WithdrawCollateralParams): Promise { + const market = await this.resolveMarketParams(params.market_id); const data = encodeFunctionData({ abi: MORPHO_ABI, - functionName: "repay", - args: [market, params.amount, 0n, params.on_behalf_of, "0x"], + functionName: "withdrawCollateral", + args: [market, params.amount, params.to, params.to], }); return { - description: `[${this.protocolName}] Repay ${params.amount} to Morpho market`, + description: `[${this.protocolName}] Withdraw collateral ${params.amount} of ${params.asset} from market ${params.market_id.slice(0, 10)}…`, to: this.morpho, data, value: 0n, @@ -215,6 +301,22 @@ export class MorphoBlueAdapter implements ILending { } async buildWithdraw(params: WithdrawParams): Promise { + // Direct Morpho Blue market (loan-side withdrawal) when caller pins marketId. + if (params.market_id) { + const market = await this.resolveMarketParams(params.market_id); + const data = encodeFunctionData({ + abi: MORPHO_ABI, + functionName: "withdraw", + args: [market, params.amount, 0n, params.to, params.to], + }); + return { + description: `[${this.protocolName}] Withdraw ${params.amount} of ${params.asset} from market ${params.market_id.slice(0, 10)}…`, + to: this.morpho, + data, + value: 0n, + gas_estimate: 300_000, + }; + } const vault = await this.resolveVault(params.asset); if (vault) { if (params.amount === MAX_UINT256) { @@ -243,19 +345,11 @@ export class MorphoBlueAdapter implements ILending { to: vault, data, value: 0n, gas_estimate: 400_000, }; } - const market = defaultMarketParams(params.asset); - const data = encodeFunctionData({ - abi: MORPHO_ABI, - functionName: "withdraw", - args: [market, params.amount, 0n, params.to, params.to], - }); - return { - description: `[${this.protocolName}] Withdraw ${params.amount} from Morpho market`, - to: this.morpho, - data, - value: 0n, - gas_estimate: 250_000, - }; + throw DefiError.invalidParam( + `[${this.protocolName}] withdraw requires either a registered MetaMorpho vault for ` + + `${params.asset} or an explicit --market . The legacy zero-MarketParams ` + + `stub was removed (it always reverted on-chain).`, + ); } async getRates(asset: Address): Promise {