> ## Documentation Index
> Fetch the complete documentation index at: https://www.dynamic.xyz/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Moonwell lending

> Let users supply USDC to Moonwell lending markets on Base and earn interest with Dynamic embedded wallets

## Overview

[Moonwell](https://moonwell.fi/) is a lending protocol on Base. Users supply an asset, borrowers pay interest on it, and that interest accrues to suppliers. This guide walks through supplying and withdrawing native USDC on Base from a Next.js app with Dynamic embedded wallets.

Moonwell is a Compound v2 fork, which has two consequences that shape the whole integration: balances are held as receipt tokens rather than as a stored amount, and some failures come back as a return code instead of a revert. Both are covered below.

For the final code, see the [GitHub repository](https://github.com/dynamic-labs-oss/examples/tree/main/examples/nextjs-defi-lending-moonwell).

## How it works

When a user supplies USDC, the market mints them mUSDC, an mToken that represents their share of the pool. The mToken balance never changes. Its exchange rate grows as borrowers pay interest, so the same balance redeems for more USDC over time.

To show what a user has supplied, multiply their mToken balance by the market's exchange rate:

```typescript theme={"system"}
suppliedUsdc = (mTokenBalance * exchangeRateStored) / 10n ** 18n;
```

**Example:** a user supplies 1,000 USDC and receives mUSDC. Nothing about their mUSDC balance changes, but once the exchange rate has risen 5%, redeeming that balance returns 1,050 USDC.

Supply APY is variable and moves with borrower demand.

## Setup

### Project setup

Follow the [JS SDK Quickstart](/docs/javascript/reference/quickstart) to scaffold a Next.js app with Dynamic, or start from the [GitHub repository](https://github.com/dynamic-labs-oss/examples/tree/main/examples/nextjs-defi-lending-moonwell) linked above.

<Info>
  In the Dynamic dashboard, enable **Base** under **Chains & Networks**, enable **Embedded wallets** under **Wallets**, and add your app's origin under **Developer Settings → CORS Origins**.
</Info>

### Install dependencies

<CodeGroup>
  ```bash npm theme={"system"}
  npm install @dynamic-labs-sdk/client @dynamic-labs-sdk/evm @dynamic-labs-sdk/react-hooks @tanstack/react-query viem
  ```

  ```bash yarn theme={"system"}
  yarn add @dynamic-labs-sdk/client @dynamic-labs-sdk/evm @dynamic-labs-sdk/react-hooks @tanstack/react-query viem
  ```

  ```bash pnpm theme={"system"}
  pnpm add @dynamic-labs-sdk/client @dynamic-labs-sdk/evm @dynamic-labs-sdk/react-hooks @tanstack/react-query viem
  ```

  ```bash bun theme={"system"}
  bun add @dynamic-labs-sdk/client @dynamic-labs-sdk/evm @dynamic-labs-sdk/react-hooks @tanstack/react-query viem
  ```
</CodeGroup>

### Environment variables

```env .env.local theme={"system"}
NEXT_PUBLIC_DYNAMIC_ENV_ID=your-environment-id-here
NEXT_PUBLIC_BASE_RPC_URL=https://your-provider.example/base-mainnet
```

Your environment ID is in the Dynamic dashboard under **Developer Settings → SDK & API Keys**.

<Warning>
  Set `NEXT_PUBLIC_BASE_RPC_URL` to an endpoint you control. The same RPC serves every balance read and broadcasts every transaction, so a shared public endpoint can rate limit your users, serve stale balances, and observe every address your app touches. Base's own public endpoint (`mainnet.base.org`) returns 403 for browser traffic, which surfaces as a failed transaction rather than a failed read.
</Warning>

### Configure constants

Create `src/lib/constants.ts`. Identify a market by its mToken address, never by symbol: Moonwell has two markets reporting the symbol `mUSDC`, and one of them is the deprecated USDbC market.

```typescript src/lib/constants.ts theme={"system"}
export const CHAIN_ID = 8453; // Base

export const MARKETS_API = "https://api.moonwell.fi/v1/markets?chainId=8453";

/** Native USDC on Base (6 decimals). */
export const USDC_ADDRESS =
  "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const;

/** Moonwell mToken for the native USDC market (8 decimals). */
export const MUSDC_ADDRESS =
  "0xEdc817A28E8B93B03976FBd4a3dDBc9f7D176c22" as const;

export const USDC_DECIMALS = 6;

export const BASE_RPC_URL = process.env.NEXT_PUBLIC_BASE_RPC_URL!;
```

<Warning>
  Hardcode the addresses your app transacts with. Market metadata from an API is fine for display, but deriving an approval spender or a transaction target from a network response means a bad response can point a user's funds somewhere unexpected.
</Warning>

### Initialize Dynamic

Create `src/lib/dynamic.ts`. The `networksData` transformer does two jobs. Restricting the EVM list to Base makes Base the default network for new embedded wallets, and putting your RPC first makes the wallet broadcast through it.

```typescript src/lib/dynamic.ts theme={"system"}
import { createDynamicClient, initializeClient } from "@dynamic-labs-sdk/client";
import { addEvmExtension } from "@dynamic-labs-sdk/evm";
import { BASE_RPC_URL, CHAIN_ID } from "@/lib/constants";

export const dynamicClient = createDynamicClient({
  autoInitialize: false,
  environmentId: process.env.NEXT_PUBLIC_DYNAMIC_ENV_ID!,
  metadata: { name: "Moonwell Lending" },
  transformers: {
    networksData: (networksData) =>
      networksData
        .filter(
          (network) =>
            network.chain !== "EVM" || Number(network.networkId) === CHAIN_ID,
        )
        .map((network) => {
          if (Number(network.networkId) !== CHAIN_ID) return network;
          return {
            ...network,
            rpcUrls: {
              ...network.rpcUrls,
              http: [BASE_RPC_URL, ...network.rpcUrls.http],
            },
          };
        }),
  },
});

// "use client" modules still execute during server rendering, where there is
// no wallet environment to initialize.
if (typeof window !== "undefined") {
  addEvmExtension();
  initializeClient().catch((error) => {
    console.error("Dynamic client failed to initialize", error);
  });
}
```

### Configure providers

Create `src/lib/providers.tsx`. Two details matter here.

Embedded wallet creation is not automatic. Trigger it after authentication, and use `getChainsMissingWaasWalletAccounts()` as the signal rather than checking whether the account list is empty, which can read stale immediately after sign-in.

Prefer the embedded wallet when selecting an account. `addEvmExtension()` also registers EIP-6963 discovery, so an external wallet can appear in the same list.

```typescript src/lib/providers.tsx theme={"system"}
"use client";

import { createContext, useContext, type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
  DynamicProvider,
  useOnEvent,
  useUser,
  useGetWalletAccounts,
} from "@dynamic-labs-sdk/react-hooks";
import {
  createWaasWalletAccounts,
  getChainsMissingWaasWalletAccounts,
  isWaasWalletAccount,
} from "@dynamic-labs-sdk/client/waas";
import type { WalletAccount } from "@dynamic-labs-sdk/client";
import { isEvmWalletAccount, type EvmWalletAccount } from "@dynamic-labs-sdk/evm";
import { dynamicClient } from "@/lib/dynamic";

interface WalletContextValue {
  evmAccount: EvmWalletAccount | null;
  loggedIn: boolean;
}

const WalletContext = createContext<WalletContextValue>({
  evmAccount: null,
  loggedIn: false,
});

export function useWallet() {
  return useContext(WalletContext);
}

const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 1000 * 5 } },
});

function WaasBootstrap() {
  useOnEvent({
    event: "userChanged",
    listener: async (user) => {
      if (!user) return;
      const missingChains = getChainsMissingWaasWalletAccounts();
      if (missingChains.length === 0) return;
      try {
        await createWaasWalletAccounts({ chains: missingChains });
      } catch (error) {
        // Nothing awaits an event listener, so an uncaught rejection is silent
        // and the UI waits forever for a wallet that never arrives.
        console.error("Embedded wallet creation failed", error);
      }
    },
  });
  return null;
}

function WalletContextProvider({ children }: { children: ReactNode }) {
  const { data: user } = useUser();
  const { data: accounts = [] } = useGetWalletAccounts();
  const evmAccounts = (accounts as WalletAccount[]).filter(isEvmWalletAccount);

  const evmAccount =
    evmAccounts.find((walletAccount) => isWaasWalletAccount({ walletAccount })) ??
    null;

  return (
    <WalletContext.Provider value={{ evmAccount, loggedIn: !!user }}>
      {children}
    </WalletContext.Provider>
  );
}

export default function Providers({ children }: { children: ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <DynamicProvider client={dynamicClient}>
        <WaasBootstrap />
        <WalletContextProvider>{children}</WalletContextProvider>
      </DynamicProvider>
    </QueryClientProvider>
  );
}
```

<Note>
  `QueryClientProvider` must sit outside `DynamicProvider`. Every hook in `@dynamic-labs-sdk/react-hooks` is built on TanStack Query.
</Note>

### Set up contract ABIs

Create the ABI files in `src/lib/ABIs/`:

* **ERC20\_ABI.ts** for the USDC interface (`balanceOf`, `allowance`, `approve`)
* **MTOKEN\_ABI.ts** for the mToken interface (`mint`, `redeem`, `redeemUnderlying`, `balanceOf`, `exchangeRateStored`)

You can copy both from the [GitHub repository](https://github.com/dynamic-labs-oss/examples/tree/main/examples/nextjs-defi-lending-moonwell/src/lib/ABIs).

## Reading balances

Create a read-only viem client and a hook that reads the three values the UI needs. The supplied balance is derived, not read: an mToken balance is constant while its exchange rate grows, so interest only appears once the two are multiplied.

```typescript src/lib/hooks/useBalances.ts theme={"system"}
"use client";

import { useQuery } from "@tanstack/react-query";
import { createPublicClient, http } from "viem";
import { base } from "viem/chains";
import { ERC20_ABI, MTOKEN_ABI } from "@/lib/ABIs";
import { BASE_RPC_URL, MUSDC_ADDRESS, USDC_ADDRESS } from "@/lib/constants";

export const publicClient = createPublicClient({
  chain: base,
  transport: http(BASE_RPC_URL),
});

export const balancesQueryKey = (address?: string) =>
  ["moonwell", "balances", address ?? "anonymous"] as const;

export function useBalances(address?: string) {
  return useQuery({
    queryKey: balancesQueryKey(address),
    enabled: !!address,
    refetchInterval: 5_000,
    queryFn: async () => {
      const owner = address as `0x${string}`;
      const [walletUsdc, mTokenBalance, exchangeRate, allowance] =
        await Promise.all([
          publicClient.readContract({
            address: USDC_ADDRESS,
            abi: ERC20_ABI,
            functionName: "balanceOf",
            args: [owner],
          }),
          publicClient.readContract({
            address: MUSDC_ADDRESS,
            abi: MTOKEN_ABI,
            functionName: "balanceOf",
            args: [owner],
          }),
          publicClient.readContract({
            address: MUSDC_ADDRESS,
            abi: MTOKEN_ABI,
            functionName: "exchangeRateStored",
          }),
          publicClient.readContract({
            address: USDC_ADDRESS,
            abi: ERC20_ABI,
            functionName: "allowance",
            args: [owner, MUSDC_ADDRESS],
          }),
        ]);

      return {
        walletUsdc,
        mTokenBalance,
        suppliedUsdc: (mTokenBalance * exchangeRate) / 10n ** 18n,
        allowance,
      };
    },
  });
}
```

`exchangeRateStored` reflects the last interest accrual, so a supplied balance can read fractionally low between accruals. For display this is fine, and the withdraw-everything path below avoids leaving dust behind.

## Building the wallet client

Put the wallet on Base before anything is signed. An embedded wallet opens on whatever network the environment treats as default, and `createWalletClientForWalletAccount` derives its chain from the wallet's current network, so the switch has to happen first.

```typescript src/lib/wallet.ts theme={"system"}
import {
  getActiveNetworkId,
  isProgrammaticNetworkSwitchAvailable,
  switchActiveNetwork,
} from "@dynamic-labs-sdk/client";
import { createWalletClientForWalletAccount } from "@dynamic-labs-sdk/evm/viem";
import type { EvmWalletAccount } from "@dynamic-labs-sdk/evm";
import { CHAIN_ID } from "@/lib/constants";

export async function getWalletClient(evmAccount: EvmWalletAccount) {
  const { networkId } = await getActiveNetworkId({ walletAccount: evmAccount });

  if (Number(networkId) !== CHAIN_ID) {
    if (!isProgrammaticNetworkSwitchAvailable({ walletAccount: evmAccount })) {
      throw new Error(
        `This wallet is on chain ${networkId} and cannot switch networks programmatically. Switch to Base in your wallet, then try again.`,
      );
    }
    await switchActiveNetwork({
      networkId: String(CHAIN_ID),
      walletAccount: evmAccount,
    });
  }

  const walletClient = await createWalletClientForWalletAccount({
    walletAccount: evmAccount,
  });

  // Re-check the chain: signing against the wrong chain's contracts is the
  // failure a silent switch would cause.
  if (walletClient.chain?.id !== CHAIN_ID) {
    throw new Error(`Wallet is still on chain ${walletClient.chain?.id}.`);
  }

  // An embedded wallet signs locally, which viem models as a `local` account.
  // A `json-rpc` account means the transaction would be forwarded to an RPC
  // that holds no keys.
  if (walletClient.account?.type !== "local") {
    throw new Error(
      `Selected wallet cannot sign locally (account type "${walletClient.account?.type}").`,
    );
  }

  return walletClient;
}
```

## Supply and withdraw

Every write follows the same sequence: simulate, assert the return code is `0n`, broadcast once, wait for the receipt.

```typescript src/lib/hooks/useLendingOperations.ts theme={"system"}
import type { Account, SimulateContractReturnType } from "viem";
import type { EvmWalletAccount } from "@dynamic-labs-sdk/evm";
import { ERC20_ABI, MTOKEN_ABI } from "@/lib/ABIs";
import { MUSDC_ADDRESS, USDC_ADDRESS } from "@/lib/constants";
import { publicClient } from "@/lib/hooks/useBalances";
import { getWalletClient } from "@/lib/wallet";

/**
 * Compound v2 markets answer some failures with a nonzero return code instead
 * of reverting, so a transaction can succeed on-chain while doing nothing.
 * Simulating first exposes that code.
 */
function assertNoErrorCode(result: unknown, action: string) {
  if (typeof result === "bigint" && result !== 0n) {
    throw new Error(`Moonwell rejected the ${action} with error code ${result}.`);
  }
}

async function run(
  evmAccount: EvmWalletAccount,
  action: string,
  simulate: (account: Account) => Promise<SimulateContractReturnType>,
) {
  const walletClient = await getWalletClient(evmAccount);

  // Pass the wallet's account object, not its address. `writeContract` prefers
  // the account carried on the simulated request, and an address string parses
  // into a `json-rpc` account, which sends `eth_sendTransaction` to the RPC
  // instead of signing locally.
  const { request, result } = await simulate(walletClient.account);
  assertNoErrorCode(result, action);

  const hash = await walletClient.writeContract(request);
  const receipt = await publicClient.waitForTransactionReceipt({ hash });
  if (receipt.status !== "success") {
    throw new Error(`${action} transaction reverted`);
  }
  return hash;
}

/** Approves the mToken to spend exactly `amount` USDC. */
export const approve = (evmAccount: EvmWalletAccount, amount: bigint) =>
  run(evmAccount, "approval", (account) =>
    publicClient.simulateContract({
      address: USDC_ADDRESS,
      abi: ERC20_ABI,
      functionName: "approve",
      args: [MUSDC_ADDRESS, amount],
      account,
    }),
  );

/** Supplies USDC and receives mUSDC. */
export const supply = (evmAccount: EvmWalletAccount, amount: bigint) =>
  run(evmAccount, "supply", (account) =>
    publicClient.simulateContract({
      address: MUSDC_ADDRESS,
      abi: MTOKEN_ABI,
      functionName: "mint",
      args: [amount],
      account,
    }),
  );

/** Withdraws an exact USDC amount. */
export const withdraw = (evmAccount: EvmWalletAccount, amount: bigint) =>
  run(evmAccount, "withdrawal", (account) =>
    publicClient.simulateContract({
      address: MUSDC_ADDRESS,
      abi: MTOKEN_ABI,
      functionName: "redeemUnderlying",
      args: [amount],
      account,
    }),
  );

/** Withdraws everything by redeeming the whole mToken balance. */
export const withdrawMax = (
  evmAccount: EvmWalletAccount,
  mTokenBalance: bigint,
) =>
  run(evmAccount, "withdrawal", (account) =>
    publicClient.simulateContract({
      address: MUSDC_ADDRESS,
      abi: MTOKEN_ABI,
      functionName: "redeem",
      args: [mTokenBalance],
      account,
    }),
  );
```

Supplying takes two transactions. Approve the mToken for the exact amount, then supply. For "withdraw everything", call `redeem` with the full mToken balance rather than `redeemUnderlying` with a quoted amount, so an exchange rate tick between quoting and mining cannot leave dust behind.

## Common pitfalls

**A successful transaction that did nothing.** `mint`, `redeem`, and `redeemUnderlying` return a uint error code on some failures instead of reverting. Simulate every write and treat any nonzero result as a refusal. Moonwell's [error codes](https://docs.moonwell.fi/) explain each value.

**A standing allowance after a failed supply.** If the approval is mined and the supply then fails, the allowance stays on-chain. Tell the user, so a retry does not look like it needs a second approval, and so they know an allowance is outstanding.

**A supply that fails right after its approval.** The allowance is on-chain but the read path may not serve it for a few seconds. Retry the simulate (a read, so retrying is free) rather than asking the user to submit again. Never retry the broadcast.

**Two markets called mUSDC.** The deprecated USDbC market reports the same symbol as the native USDC market. Match on mToken address, and filter out markets flagged `deprecated` before showing them.

**Locale-formatted amount inputs.** A `type="number"` input renders its value through the browser locale, so `4.000045` displays as `4,000045` for a comma-decimal user, which is indistinguishable from four million in an amount field. Use `type="text"` with `inputMode="decimal"` and validate against `/^\d*\.?\d{0,6}$/`, matching USDC's six decimals.

**Balances that look unchanged after a confirmed transaction.** A receipt can come from one node while the refetch is served by another that has not applied the block yet. Poll `getBlockNumber({ cacheTime: 0 })` until the RPC is serving the receipt's block, then invalidate the balance queries.

## Enable transaction simulation

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

## Run the app

<CodeGroup>
  ```bash npm theme={"system"}
  npm run dev
  ```

  ```bash yarn theme={"system"}
  yarn dev
  ```

  ```bash pnpm theme={"system"}
  pnpm dev
  ```

  ```bash bun theme={"system"}
  bun dev
  ```
</CodeGroup>

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

## Full source code

[GitHub repository →](https://github.com/dynamic-labs-oss/examples/tree/main/examples/nextjs-defi-lending-moonwell)

## Additional resources

* [Earn yield with Aave](/docs/recipes/integrations/yield/aave)
* [Morpho yield vaults](/docs/recipes/integrations/yield/morpho)
* [Moonwell documentation](https://docs.moonwell.fi/)
* [Dynamic JS SDK](/docs/javascript/reference/quickstart)
