Skip to main content

Overview

Moonwell 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.

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:
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 to scaffold a Next.js app with Dynamic, or start from the GitHub repository linked above.
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.

Install dependencies

Environment variables

.env.local
Your environment ID is in the Dynamic dashboard under Developer Settings → SDK & API Keys.
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.

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.
src/lib/constants.ts
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.

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.
src/lib/dynamic.ts

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.
src/lib/providers.tsx
QueryClientProvider must sit outside DynamicProvider. Every hook in @dynamic-labs-sdk/react-hooks is built on TanStack Query.

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.

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.
src/lib/hooks/useBalances.ts
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.
src/lib/wallet.ts

Supply and withdraw

Every write follows the same sequence: simulate, assert the return code is 0n, broadcast once, wait for the receipt.
src/lib/hooks/useLendingOperations.ts
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 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

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

Full source code

GitHub repository →

Additional resources

Last modified on August 24, 2026