What We’re Building

A React (Next.js) app that connects Dynamic’s MPC wallets to Aave V3 lending markets, allowing users to:
  • Supply assets to earn yield
  • Borrow against supplied collateral
  • Track positions and account health
  • Manage deposits and withdrawals
If you want to take a quick look at the final code, check out the GitHub repository.

Key Components

  • Dynamic MPC Wallets - Embedded, non-custodial wallets with seamless auth
  • Dynamic Transaction Simulation - Built-in transaction preview showing asset transfers, fees, and counterparties
  • Aave V3 Markets - Lending protocol with supply/borrow functionality

Building the Application

Project Setup

Start by creating a new Dynamic project with React, Viem, Wagmi, and Ethereum support:
npx create-dynamic-app@latest aave-dynamic-app --framework nextjs --library viem --wagmi true --chains ethereum --pm npm
cd aave-dynamic-app

Install Aave Dependencies

Add the Aave React SDK:
npm install @aave/react
This installs the official Aave React SDK which provides hooks and utilities for interacting with Aave V3 markets. For more info on the Aave React SDK, see the Aave React SDK documentation.

Configure Dynamic Environment

Create a .env.local file with your Dynamic environment ID:
.env.local
NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID=your-environment-id-here
You can find your Environment ID in the Dynamic dashboard under Developer Settings → SDK & API Keys.

Enable Transaction Simulation

  1. Go to your Dynamic dashboard
  2. Navigate to Developer SettingsEmbedded WalletsDynamic
  3. Enable “Show Confirmation UI” and “Transaction Simulation” toggles
This enables transaction simulation that shows users detailed transaction previews before execution, including asset transfers, fees, and contract addresses.
When enabled, users will see a comprehensive transaction preview like this when interacting with Aave markets:

Create Aave Client

You’ll also need to create an Aave client configuration. Create src/lib/aave.ts:
src/lib/aave.ts
import { AaveClient } from "@aave/react";

export const client = AaveClient.create();

Configure Providers

Add the AaveProvider to the src/lib/providers.tsx file and pass the client that you just created.
src/lib/providers.tsx
"use client";

import { DynamicContextProvider } from "@dynamic-labs/sdk-react-core";
import { EthereumWalletConnectors } from "@dynamic-labs/ethereum";
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DynamicWagmiConnector } from "@dynamic-labs/wagmi-connector";
import { config } from "@/lib/wagmi";
import { AaveProvider } from "@aave/react";
import { client } from "./aave";

export default function Providers({ children }: { children: React.ReactNode }) {
  const queryClient = new QueryClient();

  return (
    <DynamicContextProvider
      theme="auto"
      settings={{
        environmentId: process.env.NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID!,
        walletConnectors: [EthereumWalletConnectors],
      }}
    >
      <WagmiProvider config={config}>
        <AaveProvider client={client}>
          <QueryClientProvider client={queryClient}>
            <DynamicWagmiConnector>{children}</DynamicWagmiConnector>
          </QueryClientProvider>
        </AaveProvider>
      </WagmiProvider>
    </DynamicContextProvider>
  );
}

Create Transaction Operations Hook

Let’s build the transaction operations hook step by step. First, create the basic structure with imports and state management:
src/lib/useTransactionOperations.ts
import { useState } from "react";
import { WalletClient } from "viem";
import {
  supply,
  borrow,
  repay,
  withdraw,
  evmAddress,
  chainId,
} from "@aave/react";

export function useTransactionOperations(
  walletClient: WalletClient | null,
  selectedChainId: number
) {
  const [isOperating, setIsOperating] = useState(false);
  const [operationError, setOperationError] = useState<string | null>(null);

  // ... functions will be added here

  return {
    isOperating,
    operationError,
    executeSupply,
    executeBorrow,
    executeRepay,
    executeWithdraw,
  };
}
This sets up the basic hook structure with state management for loading and error handling. The walletClient parameter comes from the wallet connection, and selectedChainId determines which Aave market to interact with. Now let’s add the supply function that allows users to deposit assets and earn interest:
src/lib/useTransactionOperations.ts
  const executeSupply = async (
    marketAddress: string,
    currencyAddress: string,
    amount: string
  ) => {
    if (!walletClient) throw new Error("Wallet not connected");

    setIsOperating(true);
    setOperationError(null);

    try {
      const hash = await supply({
        market: evmAddress(marketAddress),
        amount: {
          erc20: {
            currency: evmAddress(currencyAddress),
            value: amount,
          },
        },
        supplier: evmAddress(await walletClient.account.address),
        chainId: chainId(selectedChainId),
        walletClient,
      });

      return hash;
    } catch (error) {
      setOperationError(
        error instanceof Error ? error.message : "Supply failed"
      );
      throw error;
    } finally {
      setIsOperating(false);
    }
  };
The supply function converts addresses to the proper format using evmAddress() and structures the amount as an ERC20 object. It uses the user’s wallet address as the supplier and returns the transaction hash. Add the borrow function for users to borrow assets against their supplied collateral:
src/lib/useTransactionOperations.ts
  const executeBorrow = async (
    marketAddress: string,
    currencyAddress: string,
    amount: string
  ) => {
    if (!walletClient) throw new Error("Wallet not connected");

    setIsOperating(true);
    setOperationError(null);

    try {
      const hash = await borrow({
        market: evmAddress(marketAddress),
        amount: {
          erc20: {
            currency: evmAddress(currencyAddress),
            value: amount,
          },
        },
        borrower: evmAddress(await walletClient.account.address),
        chainId: chainId(selectedChainId),
        walletClient,
      });

      return hash;
    } catch (error) {
      setOperationError(
        error instanceof Error ? error.message : "Borrow failed"
      );
      throw error;
    } finally {
      setIsOperating(false);
    }
  };
Similar to supply, the borrow function uses the user’s address as the borrower instead of supplier. This allows users to borrow assets against their existing collateral in the Aave market. Add the repay function to allow users to pay back their borrowed amounts:
src/lib/useTransactionOperations.ts
  const executeRepay = async (
    marketAddress: string,
    currencyAddress: string,
    amount: string | "max"
  ) => {
    if (!walletClient) throw new Error("Wallet not connected");

    setIsOperating(true);
    setOperationError(null);

    try {
      const hash = await repay({
        market: evmAddress(marketAddress),
        amount: {
          erc20: {
            currency: evmAddress(currencyAddress),
            value:
              amount === "max"
                ? { max: true }
                : { exact: bigDecimal(parseFloat(amount)) },
          },
        },
        borrower: evmAddress(await walletClient.account.address),
        chainId: chainId(selectedChainId),
        walletClient,
      });

      return hash;
    } catch (error) {
      setOperationError(
        error instanceof Error ? error.message : "Repay failed"
      );
      throw error;
    } finally {
      setIsOperating(false);
    }
  };
The repay function allows users to pay back their borrowed amounts, reducing their debt position. It uses the same structure as borrow but calls the Aave repay function instead. Finally, add the withdraw function for users to withdraw their supplied assets:
src/lib/useTransactionOperations.ts
  const executeWithdraw = async (
    marketAddress: string,
    currencyAddress: string,
    amount: string
  ) => {
    if (!walletClient) throw new Error("Wallet not connected");

    setIsOperating(true);
    setOperationError(null);

    try {
      const hash = await withdraw({
        market: evmAddress(marketAddress),
        amount: {
          erc20: {
            currency: evmAddress(currencyAddress),
            value: amount,
          },
        },
        supplier: evmAddress(await walletClient.account.address),
        chainId: chainId(selectedChainId),
        walletClient,
      });

      return hash;
    } catch (error) {
      setOperationError(
        error instanceof Error ? error.message : "Withdraw failed"
      );
      throw error;
    } finally {
      setIsOperating(false);
    }
  };

  return {
    isOperating,
    operationError,
    executeSupply,
    executeBorrow,
    executeRepay,
    executeWithdraw,
  };
}
The withdraw function allows users to take back their supplied assets. It mirrors the supply function but calls the Aave withdraw function, using the user’s address as the supplier to withdraw their deposited funds.

Build Market Interface Component

Create src/components/MarketsInterface.tsx for the main UI:
src/components/MarketsInterface.tsx
import {
  chainId,
  evmAddress,
  useAaveMarkets,
  useUserBorrows,
  useUserMarketState,
  useUserSupplies,
} from "@aave/react";
import { isEthereumWallet } from "@dynamic-labs/ethereum";
import { useDynamicContext } from "@dynamic-labs/sdk-react-core";
import { useEffect, useState } from "react";
import { WalletClient } from "viem";
import { base } from "viem/chains";

import { AccountHealth } from "./AccountHealth";
import { BorrowCard } from "./BorrowCard";
import { MarketCard } from "./MarketCard";
import { SupplyCard } from "./SupplyCard";
import { TransactionStatus } from "./TransactionStatus";
import { useTransactionOperations } from "../lib/useTransactionOperations";

export function MarketsInterface() {
  const { primaryWallet } = useDynamicContext();

  const [selectedChainId] = useState(base.id);
  const [walletClient, setWalletClient] = useState<WalletClient | null>(null);
  const [lastTransaction, setLastTransaction] = useState<{
    type: string;
    hash: string;
    timestamp: number;
  } | null>(null);

  useEffect(() => {
    if (primaryWallet && isEthereumWallet(primaryWallet)) {
      primaryWallet.getWalletClient().then(setWalletClient);
    }
  }, [primaryWallet]);

  const {
    isOperating,
    operationError,
    executeSupply,
    executeBorrow,
    executeRepay,
    executeWithdraw,
  } = useTransactionOperations(walletClient, selectedChainId);

  const {
    data: markets,
    loading: marketsLoading,
    error: marketsError,
  } = useAaveMarkets({
    chainIds: [chainId(selectedChainId)],
    user: primaryWallet?.address
      ? evmAddress(primaryWallet.address)
      : undefined,
  });

  const {
    data: userSupplies,
    loading: userSuppliesLoading,
    error: userSuppliesError,
  } = useUserSupplies({
    markets:
      markets?.map((market) => ({
        chainId: market.chain.chainId,
        address: market.address,
      })) || [],
    user: primaryWallet?.address
      ? evmAddress(primaryWallet.address)
      : undefined,
  });

  const {
    data: userBorrows,
    loading: userBorrowsLoading,
    error: userBorrowsError,
  } = useUserBorrows({
    markets:
      markets?.map((market) => ({
        chainId: market.chain.chainId,
        address: market.address,
      })) || [],
    user: primaryWallet?.address
      ? evmAddress(primaryWallet.address)
      : undefined,
  });

  const firstMarket = markets?.[0];
  const { data: userMarketState } = useUserMarketState({
    market:
      firstMarket?.address ||
      evmAddress("0x0000000000000000000000000000000000000000"),
    user: primaryWallet?.address
      ? evmAddress(primaryWallet.address)
      : undefined,
    chainId: chainId(selectedChainId),
  });

  const handleSupply = async (
    marketAddress: string,
    currencyAddress: string,
    amount: string
  ) => {
    try {
      const hash = await executeSupply(marketAddress, currencyAddress, amount);
      if (hash) {
        setLastTransaction({
          type: "Supply",
          hash,
          timestamp: Date.now(),
        });
      }
    } catch (error) {
      console.error("Supply failed:", error);
    }
  };

  const handleBorrow = async (
    marketAddress: string,
    currencyAddress: string,
    amount: string
  ) => {
    try {
      const hash = await executeBorrow(marketAddress, currencyAddress, amount);
      if (hash) {
        setLastTransaction({
          type: "Borrow",
          hash,
          timestamp: Date.now(),
        });
      }
    } catch (error) {
      console.error("Borrow failed:", error);
    }
  };

  const handleRepay = async (
    marketAddress: string,
    currencyAddress: string,
    amount: string | "max"
  ) => {
    try {
      const hash = await executeRepay(marketAddress, currencyAddress, amount);
      if (hash) {
        setLastTransaction({
          type: "Repay",
          hash,
          timestamp: Date.now(),
        });
      }
    } catch (error) {
      console.error("Repay failed:", error);
    }
  };

  const handleWithdraw = async (
    marketAddress: string,
    currencyAddress: string,
    amount: string
  ) => {
    try {
      const hash = await executeWithdraw(
        marketAddress,
        currencyAddress,
        amount
      );
      if (hash) {
        setLastTransaction({
          type: "Withdraw",
          hash,
          timestamp: Date.now(),
        });
      }
    } catch (error) {
      console.error("Withdraw failed:", error);
    }
  };

  return (
    <div className="max-w-6xl mx-auto p-6 space-y-6">
      <h1 className="text-3xl font-bold text-center mb-8">Aave V3 Markets</h1>

      <TransactionStatus
        isOperating={isOperating}
        operationError={operationError || null}
        lastTransaction={lastTransaction}
        primaryWallet={primaryWallet}
      />

      <div className="bg-blue-50 p-6 rounded-lg">
        <h2 className="text-2xl font-semibold mb-4">Available Markets</h2>
        {marketsLoading ? (
          <p className="text-gray-600">Loading markets...</p>
        ) : marketsError ? (
          <p className="text-red-600">
            Error loading markets: {String(marketsError)}
          </p>
        ) : markets && markets.length > 0 ? (
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
            {markets.map((market) => (
              <MarketCard
                key={market.address}
                market={market}
                isOperating={isOperating}
                primaryWallet={primaryWallet}
                onSupply={handleSupply}
                onBorrow={handleBorrow}
              />
            ))}
          </div>
        ) : (
          <p className="text-gray-600">No markets found for this chain.</p>
        )}
      </div>

      {primaryWallet && (
        <>
          <div className="bg-green-50 p-6 rounded-lg">
            <h2 className="text-2xl font-semibold mb-4">Your Supplies</h2>
            {userSuppliesLoading ? (
              <p className="text-gray-600">Loading supplies...</p>
            ) : userSuppliesError ? (
              <p className="text-red-600">
                Error loading supplies: {String(userSuppliesError)}
              </p>
            ) : userSupplies && userSupplies.length > 0 ? (
              <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
                {userSupplies.map((supply) => (
                  <SupplyCard
                    key={`${supply.market.address}-${supply.currency.address}`}
                    supply={supply}
                    isOperating={isOperating}
                    primaryWallet={primaryWallet}
                    onSupply={handleSupply}
                    onBorrow={handleBorrow}
                    onWithdraw={handleWithdraw}
                  />
                ))}
              </div>
            ) : (
              <p className="text-gray-600">No supplies found.</p>
            )}
          </div>

          <div className="bg-red-50 p-6 rounded-lg">
            <h2 className="text-2xl font-semibold mb-4">Your Borrows</h2>
            {userBorrowsLoading ? (
              <p className="text-gray-600">Loading borrows...</p>
            ) : userBorrowsError ? (
              <p className="text-red-600">
                Error loading borrows: {String(userBorrowsError)}
              </p>
            ) : userBorrows && userBorrows.length > 0 ? (
              <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
                {userBorrows.map((borrow) => (
                  <BorrowCard
                    key={`${borrow.market.address}-${borrow.currency.address}`}
                    borrow={borrow}
                    isOperating={isOperating}
                    primaryWallet={primaryWallet}
                    onRepay={handleRepay}
                  />
                ))}
              </div>
            ) : (
              <p className="text-gray-600">No borrows found.</p>
            )}
          </div>

          <AccountHealth userMarketState={userMarketState} />
        </>
      )}
    </div>
  );
}
This is the main component that orchestrates the entire Aave V3 interface. It uses Aave React SDK hooks to fetch market data, user positions, and account health. It also manages the wallet client connection and handles all transaction operations.

Create Market Card Component

Build src/components/MarketCard.tsx for individual market display:
src/components/MarketCard.tsx
import { useState } from "react";
import { Market } from "@aave/react";
import { PrimaryWallet } from "@dynamic-labs/sdk-react-core";

interface MarketCardProps {
  market: Market;
  isOperating: boolean;
  primaryWallet: PrimaryWallet | null;
  onSupply: (
    marketAddress: string,
    currencyAddress: string,
    amount: string
  ) => Promise<void>;
  onBorrow: (
    marketAddress: string,
    currencyAddress: string,
    amount: string
  ) => Promise<void>;
}

export function MarketCard({
  market,
  isOperating,
  primaryWallet,
  onSupply,
  onBorrow,
}: MarketCardProps) {
  const [supplyAmount, setSupplyAmount] = useState("");
  const [borrowAmount, setBorrowAmount] = useState("");

  const handleSupply = async () => {
    if (!supplyAmount || parseFloat(supplyAmount) <= 0) {
      alert("Please enter a valid amount");
      return;
    }
    await onSupply(market.address, market.currency.address, supplyAmount);
    setSupplyAmount("");
  };

  const handleBorrow = async () => {
    if (!borrowAmount || parseFloat(borrowAmount) <= 0) {
      alert("Please enter a valid amount");
      return;
    }
    await onBorrow(market.address, market.currency.address, borrowAmount);
    setBorrowAmount("");
  };

  return (
    <div className="bg-white p-6 rounded-lg shadow-md">
      <div className="flex items-center justify-between mb-4">
        <h3 className="text-lg font-semibold">{market.currency.symbol}</h3>
        <span className="text-sm text-gray-500">{market.currency.name}</span>
      </div>

      <div className="space-y-3 mb-4">
        <div className="flex justify-between">
          <span className="text-sm text-gray-600">Supply APY:</span>
          <span className="font-medium text-green-600">
            {market.supplyAPY
              ? `${(market.supplyAPY * 100).toFixed(2)}%`
              : "N/A"}
          </span>
        </div>
        <div className="flex justify-between">
          <span className="text-sm text-gray-600">Borrow APY:</span>
          <span className="font-medium text-red-600">
            {market.borrowAPY
              ? `${(market.borrowAPY * 100).toFixed(2)}%`
              : "N/A"}
          </span>
        </div>
        <div className="flex justify-between">
          <span className="text-sm text-gray-600">Total Supply:</span>
          <span className="font-medium">
            {market.totalSupply
              ? `${parseFloat(market.totalSupply).toLocaleString()} ${
                  market.currency.symbol
                }`
              : "N/A"}
          </span>
        </div>
      </div>

      <div className="space-y-3">
        <div>
          <input
            type="number"
            value={supplyAmount}
            onChange={(e) => setSupplyAmount(e.target.value)}
            placeholder={`Supply ${market.currency.symbol}`}
            className="w-full p-2 border border-gray-300 rounded"
            disabled={isOperating}
          />
          <button
            onClick={handleSupply}
            disabled={isOperating || !supplyAmount}
            className="w-full mt-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white py-2 rounded"
          >
            Supply
          </button>
        </div>

        <div>
          <input
            type="number"
            value={borrowAmount}
            onChange={(e) => setBorrowAmount(e.target.value)}
            placeholder={`Borrow ${market.currency.symbol}`}
            className="w-full p-2 border border-gray-300 rounded"
            disabled={isOperating}
          />
          <button
            onClick={handleBorrow}
            disabled={isOperating || !borrowAmount}
            className="w-full mt-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white py-2 rounded"
          >
            Borrow
          </button>
        </div>
      </div>
    </div>
  );
}
This component displays individual Aave markets with their APY rates and provides input fields for users to supply or borrow assets. When users interact with these buttons, transaction simulation will show a preview of the transaction before execution.

Run the Application

Start the development server:
npm run dev
The application will be available at http://localhost:3000.

Configure CORS

Add your local development URL to the CORS origins in your Dynamic dashboard under Developer Settings > CORS Origins.

Conclusion

If you want to take a look at the full source code, check out the GitHub repository. This integration demonstrates how Dynamic’s MPC wallets can seamlessly connect to DeFi protocols like Aave V3, providing users with a secure and user-friendly experience for lending and borrowing operations. The combination of Dynamic’s embedded wallets and transaction simulation makes complex DeFi interactions accessible to users while maintaining security and transparency.

Additional Resources