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: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
Configure constants
Createsrc/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
Initialize Dynamic
Createsrc/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
Createsrc/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 insrc/lib/ABIs/:
- ERC20_ABI.ts for the USDC interface (
balanceOf,allowance,approve) - MTOKEN_ABI.ts for the mToken interface (
mint,redeem,redeemUnderlying,balanceOf,exchangeRateStored)
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, andcreateWalletClientForWalletAccount 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 is0n, broadcast once, wait for the receipt.
src/lib/hooks/useLendingOperations.ts
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
http://localhost:3000 to your allowed origins in the Dynamic dashboard under Developer Settings → CORS Origins.