Skip to main content

Overview

Morpho vaults pool deposited assets into lending markets where borrowers pay interest. That interest flows back to depositors automatically. This guide walks through integrating Morpho vaults into a Next.js app with Dynamic embedded wallets. For the final code, see the GitHub repository.

How it works

When a user deposits, they receive vault tokens representing their share of the pool. As borrowers pay interest, each vault token becomes redeemable for more of the underlying asset. When the user withdraws, they get back their original deposit plus accrued yield. No claiming or compounding is required. Example: A user deposits 1,000 USDC when each vault token is worth 1.00 USDC. When the token value rises to 1.05 USDC, those tokens are worth 1,050 USDC. APY is variable and adjusts based on borrower demand and the curator’s allocation strategy.

Setup

Project setup

Follow the React Quickstart using the Custom setup path: Ethereum (EVM) with Wagmi and viem. Scaffold a Next.js app with create-next-app and mirror the provider wiring from the quickstart or the GitHub repository linked above.
In the Dynamic dashboard, enable Ethereum under Chains & Networks, enable Embedded wallets under Wallets, and add your app’s origin under Security → Allowed Origins.

Environment variables

.env.local
NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID=your-environment-id-here
Your environment ID is in the Dynamic dashboard under Developer Settings → SDK & API Keys.

Configure networks and constants

The example uses a per-chain network config to support Base, Ethereum mainnet, Arbitrum, Optimism, and Polygon. Create src/lib/networks.ts:
src/lib/networks.ts
import { base, mainnet, arbitrum, optimism, polygon } from "wagmi/chains";

export interface NetworkConfig {
  chainId: number;
  name: string;
  displayName: string;
  contracts: {
    rewardsDistributor: string;
    morphoMarkets: string;
  };
  marketParams?: {
    loanToken: string;
    collateralToken: string;
    oracle: string;
    irm: string;
    lltv: bigint;
  };
  api?: {
    morphoGraphql: string;
  };
  decimals?: {
    morpho: number;
    weth: number;
  };
}

export const SUPPORTED_NETWORKS: Record<number, NetworkConfig> = {
  [base.id]: {
    chainId: base.id,
    name: "base",
    displayName: "Base",
    contracts: {
      rewardsDistributor: "0x3B14E5C73e0a56D607A8688098326fD4b4292135",
      morphoMarkets: "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb",
    },
    marketParams: {
      loanToken: "0x4200000000000000000000000000000000000006",   // WETH
      collateralToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC
      oracle: "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70",
      irm: "0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC",
      lltv: BigInt("850000000000000000"), // 85%
    },
    api: { morphoGraphql: "https://api.morpho.org/graphql" },
    decimals: { morpho: 18, weth: 18 },
  },
  [mainnet.id]: {
    chainId: mainnet.id,
    name: "mainnet",
    displayName: "Ethereum",
    contracts: {
      rewardsDistributor: "0x3B14E5C73e0a56D607A8688098326fD4b4292135",
      morphoMarkets: "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb",
    },
    api: { morphoGraphql: "https://api.morpho.org/graphql" },
    decimals: { morpho: 18, weth: 18 },
  },
  [arbitrum.id]: {
    chainId: arbitrum.id,
    name: "arbitrum",
    displayName: "Arbitrum",
    contracts: {
      rewardsDistributor: "0x3B14E5C73e0a56D607A8688098326fD4b4292135",
      morphoMarkets: "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb",
    },
    api: { morphoGraphql: "https://api.morpho.org/graphql" },
    decimals: { morpho: 18, weth: 18 },
  },
  [optimism.id]: {
    chainId: optimism.id,
    name: "optimism",
    displayName: "Optimism",
    contracts: {
      rewardsDistributor: "0x3B14E5C73e0a56D607A8688098326fD4b4292135",
      morphoMarkets: "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb",
    },
    api: { morphoGraphql: "https://api.morpho.org/graphql" },
    decimals: { morpho: 18, weth: 18 },
  },
  [polygon.id]: {
    chainId: polygon.id,
    name: "polygon",
    displayName: "Polygon",
    contracts: {
      rewardsDistributor: "0x3B14E5C73e0a56D607A8688098326fD4b4292135",
      morphoMarkets: "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb",
    },
    api: { morphoGraphql: "https://api.morpho.org/graphql" },
    decimals: { morpho: 18, weth: 18 },
  },
};

export const DEFAULT_NETWORK = base.id;
export const SUPPORTED_CHAIN_IDS = Object.keys(SUPPORTED_NETWORKS).map(Number);

export function getNetworkConfig(chainId: number): NetworkConfig | undefined {
  return SUPPORTED_NETWORKS[chainId];
}

export function getNetworkConfigOrDefault(chainId: number): NetworkConfig {
  return getNetworkConfig(chainId) || SUPPORTED_NETWORKS[DEFAULT_NETWORK];
}

export function isNetworkSupported(chainId: number): boolean {
  return chainId in SUPPORTED_NETWORKS;
}
Then create src/lib/constants.ts which re-exports the legacy values and adds the getApiForChain helper used by the hooks:
src/lib/constants.ts
import { getNetworkConfigOrDefault, DEFAULT_NETWORK } from "./networks";

export const CONTRACTS = {
  REWARDS_DISTRIBUTOR: "0x3B14E5C73e0a56D607A8688098326fD4b4292135",
  MORPHO_MARKETS: "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb",
} as const;

export const MARKET_PARAMS = {
  loanToken: "0x4200000000000000000000000000000000000006",
  collateralToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
  oracle: "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70",
  irm: "0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC",
  lltv: BigInt("850000000000000000"),
} as const;

export const NETWORK = {
  CHAIN_ID: DEFAULT_NETWORK,
  NAME: "Base",
} as const;

export const API = {
  MORPHO_GRAPHQL: "https://api.morpho.org/graphql",
} as const;

export const DECIMALS = {
  MORPHO: 18,
  WETH: 18,
} as const;

export function getApiForChain(chainId: number) {
  const config = getNetworkConfigOrDefault(chainId);
  return config.api;
}

Set up contract ABIs

Create the ABI files in src/lib/ABIs/:
  • ERC20_ABI.ts — standard token interface (balanceOf, approve, allowance)
  • ERC4626_ABI.ts — vault interface (deposit, withdraw, balanceOf, convertToAssets)
  • REWARDS_ABI.ts — Morpho rewards distributor (getUserRewardBalance, getRewardToken)
  • MORPHO_MARKETS_ABI.ts — core Morpho markets contract
You can find these files in the GitHub repository.

Fetching vault data

Vault list

Create src/lib/hooks/useVaultsList.ts to load all available vaults. The hook reads the active chain from wagmi and queries the Morpho API for vaults on that chain:
src/lib/hooks/useVaultsList.ts
import { useState, useEffect } from "react";
import { useChainId } from "wagmi";
import { getApiForChain } from "../constants";

export interface Vault {
  id: string;
  address: string;
  name: string;
  symbol: string;
  asset: string;
  apy: string;
  netApy: string;
  tvl: string;
  description: string;
  whitelisted: boolean;
  totalSupply: string;
  sharePrice: string;
  rewards: Reward[];
}

export interface Reward {
  asset: string;
  supplyApr: string;
  yearlySupplyTokens: string;
}

export type SortOption =
  | "netApy-desc"
  | "apy-desc"
  | "tvl-desc"
  | "whitelisted-desc"
  | "totalSupply-desc"
  | "name-asc";

export function useVaultsList(sortBy: SortOption = "netApy-desc") {
  const chainId = useChainId();
  const [vaults, setVaults] = useState<Vault[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const parseNumericValue = (value: string): number => {
      if (value === "N/A") return 0;
      return parseFloat(value.replace(/[%$,]/g, "")) || 0;
    };

    const parseTVL = (tvl: string): number => {
      if (tvl === "N/A") return 0;
      const clean = tvl.replace(/[$,]/g, "");
      return clean.includes("M")
        ? parseFloat(clean.replace("M", "")) * 1e6
        : parseFloat(clean) || 0;
    };

    const sortVaults = (items: Vault[]): Vault[] => {
      return [...items].sort((a, b) => {
        switch (sortBy) {
          case "netApy-desc":   return parseNumericValue(b.netApy) - parseNumericValue(a.netApy);
          case "apy-desc":      return parseNumericValue(b.apy) - parseNumericValue(a.apy);
          case "tvl-desc":      return parseTVL(b.tvl) - parseTVL(a.tvl);
          case "whitelisted-desc":
            if (a.whitelisted !== b.whitelisted) return a.whitelisted ? -1 : 1;
            return parseNumericValue(b.netApy) - parseNumericValue(a.netApy);
          case "totalSupply-desc": return parseNumericValue(b.totalSupply) - parseNumericValue(a.totalSupply);
          case "name-asc":      return a.name.localeCompare(b.name);
          default:              return parseNumericValue(b.netApy) - parseNumericValue(a.netApy);
        }
      });
    };

    async function fetchVaults() {
      try {
        setLoading(true);
        setError(null);

        const api = getApiForChain(chainId);
        if (!api?.morphoGraphql) throw new Error("API endpoint not available for this network");

        const res = await fetch(api.morphoGraphql, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            query: `query GetVaults($chainId: Int!) {
              vaults(where: {chainId_in: [$chainId]}) {
                items {
                  id
                  address
                  name
                  symbol
                  whitelisted
                  asset {
                    address
                    symbol
                    decimals
                  }
                  state {
                    totalAssets
                    totalAssetsUsd
                    totalSupply
                    avgNetApy
                    allTimeApy
                    apy
                    netApy
                    netApyWithoutRewards
                    sharePrice
                    sharePriceUsd
                    rewards {
                      asset { address symbol }
                      supplyApr
                      yearlySupplyTokens
                    }
                  }
                }
              }
            }`,
            variables: { chainId },
          }),
        });

        if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);

        const json = await res.json();
        const items = json?.data?.vaults?.items || [];

        const formatted: Vault[] = items.map((vault: any) => {
          const apy = vault.state?.apy ? `${(vault.state.apy * 100).toFixed(2)}%` : "N/A";
          const netApy = vault.state?.netApy ? `${(vault.state.netApy * 100).toFixed(2)}%` : apy;
          return {
            id: vault.id,
            address: vault.address,
            name: vault.name || `${vault.asset?.symbol || "Unknown"} Vault`,
            symbol: vault.symbol,
            asset: vault.asset?.symbol || "Unknown",
            apy,
            netApy,
            tvl: vault.state?.totalAssetsUsd ? `$${(vault.state.totalAssetsUsd / 1e6).toFixed(1)}M` : "N/A",
            description: `Earn yield on ${vault.asset?.symbol || "Unknown"} deposits`,
            whitelisted: vault.whitelisted || false,
            totalSupply: vault.state?.totalSupply
              ? (Number(vault.state.totalSupply) / 1e18).toLocaleString(undefined, { maximumFractionDigits: 0 })
              : "N/A",
            sharePrice: vault.state?.sharePriceUsd ? `$${vault.state.sharePriceUsd.toFixed(6)}` : "N/A",
            rewards: (vault.state?.rewards || []).map((r: any) => ({
              asset: r.asset?.symbol || "Unknown",
              supplyApr: r.supplyApr ? `${(r.supplyApr * 100).toFixed(2)}%` : "N/A",
              yearlySupplyTokens: r.yearlySupplyTokens || "N/A",
            })),
          };
        });

        setVaults(sortVaults(formatted));
      } catch (err) {
        setError(err instanceof Error ? err.message : "Failed to fetch vaults");
      } finally {
        setLoading(false);
      }
    }

    fetchVaults();
  }, [sortBy, chainId]);

  return { vaults, loading, error };
}
Morpho Vaults List Page

Vault detail

When a user selects a vault, fetch its full details (including assetAddress and assetDecimals) before performing any operations. Note the query uses vault(id:) to fetch a single vault by ID, not by address. Create src/lib/hooks/useVaultDetail.ts:
src/lib/hooks/useVaultDetail.ts
import { useState, useEffect } from "react";
import { useChainId } from "wagmi";
import { getApiForChain } from "../constants";

export interface VaultDetail {
  id: string;
  address: string;
  name: string;
  symbol: string;
  asset: string;
  assetAddress: string;
  assetDecimals: number;
  apy: string;
  netApy: string;
  tvl: string;
  totalAssets: string;
  totalAssetsUsd: number;
  description: string;
  whitelisted: boolean;
  totalSupply: string;
  sharePrice: string;
  curator: string;
  guardian: string;
  owner: string;
  fee: string;
  feeRecipient: string;
  timelock: string;
  creationTimestamp: string;
  rewards: Reward[];
}

export interface Reward {
  asset: string;
  supplyApr: string;
  yearlySupplyTokens: string;
}

export function useVaultDetail(vaultId: string | undefined) {
  const chainId = useChainId();
  const [vault, setVault] = useState<VaultDetail | null>(null);
  const [loading, setLoading] = useState(true);
  const [refetching, setRefetching] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    async function fetchVaultDetail() {
      if (!vaultId) {
        setError("No vault ID provided");
        setLoading(false);
        return;
      }

      try {
        if (!vault) setLoading(true);
        else setRefetching(true);
        setError(null);

        const api = getApiForChain(chainId);
        if (!api?.morphoGraphql) throw new Error("API endpoint not available for this network");

        const res = await fetch(api.morphoGraphql, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            query: `query GetVaultDetail($vaultId: String!) {
              vault(id: $vaultId) {
                id
                address
                name
                symbol
                whitelisted
                creationTimestamp
                asset {
                  address
                  symbol
                  decimals
                }
                state {
                  totalAssets
                  totalAssetsUsd
                  totalSupply
                  avgNetApy
                  allTimeApy
                  apy
                  netApy
                  netApyWithoutRewards
                  sharePrice
                  sharePriceUsd
                  curator
                  guardian
                  owner
                  fee
                  feeRecipient
                  timelock
                  rewards {
                    asset { address symbol }
                    supplyApr
                    yearlySupplyTokens
                  }
                }
              }
            }`,
            variables: { vaultId },
          }),
        });

        if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);

        const json = await res.json();
        const v = json?.data?.vault;
        if (!v) { setError("Vault not found"); setVault(null); return; }

        setVault({
          id: v.id,
          address: v.address,
          name: v.name || `${v.asset?.symbol || "Unknown"} Vault`,
          symbol: v.symbol,
          asset: v.asset?.symbol || "Unknown",
          assetAddress: v.asset?.address || "",
          assetDecimals: v.asset?.decimals || 6,
          apy: v.state?.apy ? `${(v.state.apy * 100).toFixed(2)}%` : "N/A",
          netApy: v.state?.netApy ? `${(v.state.netApy * 100).toFixed(2)}%` : "N/A",
          tvl: v.state?.totalAssetsUsd ? `$${(v.state.totalAssetsUsd / 1e6).toFixed(1)}M` : "N/A",
          totalAssets: v.state?.totalAssets || "0",
          totalAssetsUsd: v.state?.totalAssetsUsd || 0,
          description: `Earn yield on ${v.asset?.symbol || "Unknown"} deposits`,
          whitelisted: v.whitelisted || false,
          totalSupply: v.state?.totalSupply
            ? (Number(v.state.totalSupply) / 1e18).toLocaleString(undefined, { maximumFractionDigits: 0 })
            : "N/A",
          sharePrice: v.state?.sharePriceUsd ? `$${v.state.sharePriceUsd.toFixed(6)}` : "N/A",
          curator: v.state?.curator || "N/A",
          guardian: v.state?.guardian || "N/A",
          owner: v.state?.owner || "N/A",
          fee: v.state?.fee ? `${(v.state.fee * 100).toFixed(2)}%` : "N/A",
          feeRecipient: v.state?.feeRecipient || "N/A",
          timelock: v.state?.timelock ? `${Number(v.state.timelock)} seconds` : "N/A",
          creationTimestamp: v.creationTimestamp
            ? new Date(Number(v.creationTimestamp) * 1000).toLocaleDateString()
            : "N/A",
          rewards: (v.state?.rewards || []).map((r: any) => ({
            asset: r.asset?.symbol || "Unknown",
            supplyApr: r.supplyApr ? `${(r.supplyApr * 100).toFixed(2)}%` : "N/A",
            yearlySupplyTokens: r.yearlySupplyTokens || "N/A",
          })),
        });
        setError(null);
      } catch (err) {
        setError(err instanceof Error ? err.message : "Failed to fetch vault details");
        setVault(null);
      } finally {
        setLoading(false);
        setRefetching(false);
      }
    }

    fetchVaultDetail();
  }, [vaultId, chainId]);

  return { vault, loading, refetching, error };
}
assetAddress and assetDecimals from this hook are passed into useVaultOperations to correctly encode transaction amounts.

Deposit and withdraw

Deposits require a two-step flow: the user must first approve the vault to spend their tokens, then deposit. useVaultOperations handles this automatically — when handleApprove succeeds, it sets a pendingDeposit flag and triggers the deposit automatically via onSuccess. Create src/lib/hooks/useVaultOperations.ts:
src/lib/hooks/useVaultOperations.ts
import { useState } from "react";
import { parseUnits } from "viem";
import { useReadContract, useWriteContract } from "wagmi";
import { useQueryClient } from "@tanstack/react-query";
import { ERC20_ABI, ERC4626_ABI } from "../ABIs";

interface VaultInfo {
  address: string;
  asset: { address: string; symbol: string; decimals: number };
}

export function useVaultOperations(address: string | undefined, vaultInfo: VaultInfo | null) {
  const [amount, setAmount] = useState("");
  const [txStatus, setTxStatus] = useState("");
  const [pendingDeposit, setPendingDeposit] = useState(false);
  const queryClient = useQueryClient();

  const refetchData = () => queryClient.invalidateQueries();

  // User's wallet balance of the underlying token (how much they can deposit)
  const { data: assetBalance } = useReadContract({
    address: vaultInfo?.asset.address as `0x${string}`,
    abi: ERC20_ABI,
    functionName: "balanceOf",
    args: address ? [address] : undefined,
    query: { enabled: !!address && !!vaultInfo?.asset.address },
  });

  // User's vault token balance
  const { data: vaultBalance } = useReadContract({
    address: vaultInfo?.address as `0x${string}`,
    abi: ERC4626_ABI,
    functionName: "balanceOf",
    args: address ? [address] : undefined,
    query: { enabled: !!address && !!vaultInfo?.address },
  });

  // Convert vault tokens back to underlying asset amount —
  // this is the actual value of the position, which grows as yield accrues
  const { data: depositedAssets } = useReadContract({
    address: vaultInfo?.address as `0x${string}`,
    abi: ERC4626_ABI,
    functionName: "convertToAssets",
    args: vaultBalance ? [vaultBalance] : undefined,
    query: { enabled: !!vaultInfo?.address && !!vaultBalance },
  });

  // How much the vault is approved to spend on the user's behalf
  const { data: allowance } = useReadContract({
    address: vaultInfo?.asset.address as `0x${string}`,
    abi: ERC20_ABI,
    functionName: "allowance",
    args: address && vaultInfo?.address ? [address, vaultInfo.address] : undefined,
    query: { enabled: !!address && !!vaultInfo?.asset.address && !!vaultInfo?.address },
  });

  const { writeContract: writeApprove, isPending: isApproving, error: approveError } = useWriteContract({
    mutation: {
      onSuccess: () => {
        setTxStatus("Approval sent!");
        refetchData();
        // Automatically trigger the deposit once approval is confirmed
        if (pendingDeposit && vaultInfo && address) {
          setTimeout(() => handleDepositAfterApproval(), 1000);
        }
      },
      onError: (error) => {
        setTxStatus(`Approval failed: ${error.message}`);
        setPendingDeposit(false);
      },
    },
  });

  const { writeContract: writeDeposit, isPending: isDepositing, error: depositError } = useWriteContract({
    mutation: {
      onSuccess: () => {
        setTxStatus("Deposit sent!");
        refetchData();
        setPendingDeposit(false);
      },
      onError: (error) => {
        setTxStatus(`Deposit failed: ${error.message}`);
        setPendingDeposit(false);
      },
    },
  });

  const { writeContract: writeWithdraw, isPending: isWithdrawing, error: withdrawError } = useWriteContract({
    mutation: {
      onSuccess: () => {
        setTxStatus("Withdrawal sent!");
        refetchData();
      },
      onError: (error) => {
        setTxStatus(`Withdrawal failed: ${error.message}`);
      },
    },
  });

  const handleDepositAfterApproval = async () => {
    if (!vaultInfo?.address || !address) return;
    await writeDeposit({
      address: vaultInfo.address as `0x${string}`,
      abi: ERC4626_ABI,
      functionName: "deposit",
      args: [parseUnits(amount, vaultInfo.asset.decimals), address],
    });
  };

  const handleApprove = async () => {
    if (!vaultInfo?.asset.address || !vaultInfo?.address) return;
    setTxStatus("");
    setPendingDeposit(true); // deposit will auto-fire after approval succeeds
    await writeApprove({
      address: vaultInfo.asset.address as `0x${string}`,
      abi: ERC20_ABI,
      functionName: "approve",
      args: [vaultInfo.address, parseUnits(amount, vaultInfo.asset.decimals)],
    });
  };

  // handleDeposit and handleWithdraw accept the form event so they can be
  // used directly as onSubmit handlers
  const handleDeposit = async (e: React.FormEvent) => {
    if (!vaultInfo?.address || !address) return;
    e.preventDefault();
    setTxStatus("");
    await writeDeposit({
      address: vaultInfo.address as `0x${string}`,
      abi: ERC4626_ABI,
      functionName: "deposit",
      args: [parseUnits(amount, vaultInfo.asset.decimals), address],
    });
  };

  const handleWithdraw = async (e: React.FormEvent) => {
    if (!vaultInfo?.address || !address) return;
    e.preventDefault();
    setTxStatus("");
    await writeWithdraw({
      address: vaultInfo.address as `0x${string}`,
      abi: ERC4626_ABI,
      functionName: "withdraw",
      args: [parseUnits(amount, vaultInfo.asset.decimals), address, address],
    });
  };

  // True when the vault isn't yet approved to spend the entered amount
  const needsApproval =
    (allowance !== undefined &&
      vaultInfo?.asset.decimals !== undefined &&
      parseUnits(amount || "0", vaultInfo.asset.decimals) > (allowance as bigint)) ||
    false;

  return {
    amount,
    setAmount,
    txStatus,
    setTxStatus,
    pendingDeposit,
    assetBalance,
    vaultBalance,
    depositedAssets,
    allowance,
    isApproving,
    isDepositing,
    isWithdrawing,
    approveError,
    depositError,
    withdrawError,
    handleApprove,
    handleDeposit,
    handleWithdraw,
    needsApproval,
  };
}

Wiring it together

On a vault detail page, use useVaultDetail to load vault data, construct a vaultInfo object from it, and pass that to useVaultOperations. The submit handler checks needsApproval first — if approval is needed, handleApprove fires and the deposit follows automatically once it’s confirmed.
src/app/earn/[vaultId]/page.tsx
"use client";

import { useParams } from "next/navigation";
import { useState } from "react";
import { formatUnits } from "viem";
import { useAccount } from "wagmi";
import { useVaultDetail } from "../../../lib/hooks/useVaultDetail";
import { useVaultOperations } from "../../../lib/hooks/useVaultOperations";

export default function VaultDetailPage() {
  const params = useParams();
  const vaultId = params.vaultId as string;
  const { address } = useAccount();
  const [mode, setMode] = useState<"deposit" | "withdraw">("deposit");

  const { vault, loading, error } = useVaultDetail(vaultId);

  // Build the vaultInfo object from the detail hook
  const vaultInfo = vault
    ? {
        address: vault.address,
        asset: {
          address: vault.assetAddress,
          symbol: vault.asset,
          decimals: vault.assetDecimals,
        },
      }
    : null;

  const {
    assetBalance,
    vaultBalance,
    depositedAssets,
    amount,
    setAmount,
    txStatus,
    pendingDeposit,
    handleApprove,
    handleDeposit,
    handleWithdraw,
    isApproving,
    isDepositing,
    isWithdrawing,
    needsApproval,
  } = useVaultOperations(address, vaultInfo);

  const isLoading = isApproving || isDepositing || isWithdrawing;

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!amount || parseFloat(amount) <= 0) return;
    try {
      if (needsApproval) {
        // Approval fires first; deposit auto-triggers on success via onSuccess callback
        await handleApprove();
      } else if (mode === "deposit") {
        await handleDeposit(e);
      } else {
        await handleWithdraw(e);
      }
      setAmount("");
    } catch (error) {
      console.error("Transaction failed:", error);
    }
  };

  const setMaxAmount = () => {
    if (!vault) return;
    // For deposit, max is the wallet balance. For withdraw, max is the deposited amount.
    if (mode === "deposit") {
      setAmount(assetBalance ? formatUnits(assetBalance as bigint, vault.assetDecimals) : "0");
    } else {
      setAmount(depositedAssets ? formatUnits(depositedAssets as bigint, vault.assetDecimals) : "0");
    }
  };

  // vault.apy, vault.netApy, vault.tvl, vault.sharePrice,
  // vault.curator, vault.fee are all available for display.
  //
  // depositedAssets is the user's current position value in the underlying token.
  // assetBalance is what they hold in their wallet.
  //
  // When needsApproval is true, show "Approve & Deposit" as the button label.
  // When pendingDeposit is true during isApproving, show "Approving & Depositing...".
}
Morpho Vault Detail Page

Enable transaction simulation

Dynamic’s embedded wallets include built-in transaction previews. To enable, go to Developer Settings → Embedded Wallets → Dynamic in the dashboard and toggle on Show Confirmation UI and Transaction Simulation. Users will see the exact assets being transferred, estimated fees, and the vault contract before confirming.

Run the app

npm run dev
Add http://localhost:3000 to your allowed origins in the Dynamic dashboard under Developer Settings → CORS Origins.

Full source code

GitHub repository →

Additional resources