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

# Building an Earn UI

> List Earn vaults, show a wallet's position, deposit, withdraw, and claim rewards.

## Prerequisites

Before this page: a connected or embedded wallet on the chain of each vault you offer (see [Connecting external wallets](https://docs.dynamic.xyz/docs/javascript/building-ui/connecting-external-wallets.md)). [Earn](https://docs.dynamic.xyz/docs/earn/overview.md) vault actions run on EVM and Solana, so EVM vaults need an **EVM** wallet and Solana vaults need a **Solana** wallet.

React Native calls the same Earn functions as the web, and `@dynamic-labs-sdk/react-hooks` works there too, so the examples below carry a React Native tab wherever the surrounding component differs. Two things to have in place first: the extension for each chain you offer vaults on registered on the client, which is `addEvmExtension` for EVM vaults and `addSolanaExtension` for Solana vaults, and [React Native Setup](https://docs.dynamic.xyz/docs/javascript/react-native/setup.md) finished, which covers the `metadata.universalLink` an embedded wallet signs through.

## What you'll build

An Earn flow has four parts:

1. **List** vaults and show their APY and curator.
2. **Show** a wallet's position (shares and asset value) in a vault.
3. **Deposit / withdraw**: sign and submit from the user's wallet.
4. **Rewards**: show accrued rewards and claim them, for vaults whose provider supports rewards.

Amounts you pass to `depositToYieldVault` and `withdrawFromYieldVault` are **human-readable** (`'10.5'` for 10.5 USDC). Amounts the SDK returns (`shares`, `assets`, `accrued`) are **raw on-chain units**, so divide by `assetDecimals` from `getYieldDetails` before displaying them.

## Listing vaults

`listYieldVaults` returns every vault the current environment can see, each with a best-effort `netApy` and `tvlUsd`.

> [tab] TypeScript
>
> ```typescript theme={"system"}
> import { listYieldVaults } from '@dynamic-labs-sdk/client';
>
> async function loadVaults() {
>   const vaults = await listYieldVaults();
>
>   // renderVaultList is your implementation.
>   renderVaultList(vaults);
> }
> ```

> [tab] React
>
> ```tsx theme={"system"}
> import { useListYieldVaults } from '@dynamic-labs-sdk/react-hooks';
>
> function VaultList({ onSelect }: { onSelect: (vaultId: string) => void }) {
>   const { data: vaults = [], isLoading } = useListYieldVaults();
>
>   if (isLoading) {
>     return <p>Loading vaults...</p>;
>   }
>
>   return (
>     <ul>
>       {vaults.map((vault) => (
>         <li key={vault.vaultId}>
>           <button onClick={() => onSelect(vault.vaultId)}>
>             {vault.assetSymbol} — {vault.curator} — APY: {vault.netApy ?? 'n/a'}
>           </button>
>         </li>
>       ))}
>     </ul>
>   );
> }
> ```

> [tab] React Native
>
> ```tsx theme={"system"}
> import { useListYieldVaults } from '@dynamic-labs-sdk/react-hooks';
> import { FlatList, Pressable, Text } from 'react-native';
>
> function VaultList({ onSelect }: { onSelect: (vaultId: string) => void }) {
>   const { data: vaults = [], isLoading } = useListYieldVaults();
>
>   if (isLoading) {
>     return <Text>Loading vaults...</Text>;
>   }
>
>   return (
>     <FlatList
>       data={vaults}
>       keyExtractor={(vault) => vault.vaultId}
>       renderItem={({ item: vault }) => (
>         <Pressable onPress={() => onSelect(vault.vaultId)}>
>           <Text>
>             {vault.assetSymbol} - {vault.curator} - APY: {vault.netApy ?? 'n/a'}
>           </Text>
>         </Pressable>
>       )}
>     />
>   );
> }
> ```

## Choosing the wallet for a vault

Every vault has a `chainName`, and the wallet account you pass to the other Earn functions must come from the same chain family. An `EVM` vault takes an EVM wallet account, and a `SOL` vault takes a Solana wallet account. The API rejects a wallet account from the other family with an HTTP 400 error, so pick the account from the vault instead of from whichever wallet is active.

```typescript theme={"system"}
import { getWalletAccounts, type WalletAccount } from '@dynamic-labs-sdk/client';
import { isEvmWalletAccount } from '@dynamic-labs-sdk/evm';
import { isSolanaWalletAccount } from '@dynamic-labs-sdk/solana';

function getWalletAccountForVault(vault: { chainName?: string }): WalletAccount | undefined {
  const isSolanaVault = vault.chainName === 'SOL';

  return getWalletAccounts().find((account) =>
    isSolanaVault ? isSolanaWalletAccount(account) : isEvmWalletAccount(account),
  );
}
```

## Showing a wallet's position

`getYieldPosition` reports the position the API has indexed, which can trail a deposit or withdrawal that is already mined. Treat a position that has not moved yet as "not indexed" rather than "did not happen": poll it for a bounded number of attempts after a write, and drive your success state off the transaction hash the write resolved with. Pair it with `getYieldDetails` to format `shares`/`assets` — both are raw units, scaled by `assetDecimals`.

> [tab] TypeScript
>
> ```typescript theme={"system"}
> import { getYieldDetails, getYieldPosition, type WalletAccount } from '@dynamic-labs-sdk/client';
> import { formatUnits } from 'viem';
>
> async function loadPosition(vaultId: string, walletAccount: WalletAccount) {
>   const [details, position] = await Promise.all([
>     getYieldDetails({ vaultId }),
>     getYieldPosition({ vaultId, walletAccount }),
>   ]);
>
>   return formatUnits(BigInt(position.assets), details.assetDecimals);
> }
> ```

> [tab] React
>
> ```tsx theme={"system"}
> import { type WalletAccount } from '@dynamic-labs-sdk/client';
> import { useGetYieldDetails, useGetYieldPosition } from '@dynamic-labs-sdk/react-hooks';
> import { formatUnits } from 'viem';
>
> function Position({
>   vaultId,
>   walletAccount,
> }: {
>   vaultId: string;
>   walletAccount: WalletAccount | undefined;
> }) {
>   const { data: details } = useGetYieldDetails({ vaultId });
>   const { data: position, isLoading } = useGetYieldPosition({
>     vaultId,
>     walletAccount,
>   });
>
>   if (isLoading || !details || !position) {
>     return <p>Loading position...</p>;
>   }
>
>   return (
>     <p>
>       Balance: {formatUnits(BigInt(position.assets), details.assetDecimals)}{' '}
>       {details.assetSymbol}
>     </p>
>   );
> }
> ```
>
> `useGetYieldPosition` stays disabled until `walletAccount` is defined, so it's safe to render before a wallet is connected.

> [tab] React Native
>
> ```tsx theme={"system"}
> import { type WalletAccount } from '@dynamic-labs-sdk/client';
> import { useGetYieldDetails, useGetYieldPosition } from '@dynamic-labs-sdk/react-hooks';
> import { Text } from 'react-native';
> import { formatUnits } from 'viem';
>
> function Position({
>   vaultId,
>   walletAccount,
> }: {
>   vaultId: string;
>   walletAccount: WalletAccount | undefined;
> }) {
>   const { data: details } = useGetYieldDetails({ vaultId });
>   const { data: position, isLoading } = useGetYieldPosition({
>     vaultId,
>     walletAccount,
>   });
>
>   if (isLoading || !details || !position) {
>     return <Text>Loading position...</Text>;
>   }
>
>   return (
>     <Text>
>       Balance: {formatUnits(BigInt(position.assets), details.assetDecimals)}{' '}
>       {details.assetSymbol}
>     </Text>
>   );
> }
> ```
>
> After a deposit or withdrawal, refetch on an interval until the value moves instead of once, so the screen does not show a stale balance for a transaction the chain has already accepted.

## Depositing

`depositToYieldVault` takes a human-readable `amount` and does everything else itself: it requests the signing payload, signs it, and broadcasts it. For an EVM vault, if the wallet's allowance is short of `amount`, it signs an ERC-20 approval first, and `onStepChange` tells you which step is running. A Solana vault has no approval, but a deposit can take up to three transactions, which [`onTransactionStepChange`](#solana-vaults-take-several-transactions) reports.

> [tab] TypeScript
>
> ```typescript theme={"system"}
> import { depositToYieldVault, type WalletAccount } from '@dynamic-labs-sdk/client';
>
> async function deposit(
>   vaultId: string,
>   walletAccount: WalletAccount,
>   amount: string,
> ) {
>   const { transactionHash } = await depositToYieldVault({
>     vaultId,
>     walletAccount,
>     amount,
>     onStepChange: (step) => {
>       // updateStep is your implementation — 'approval' or 'transaction'.
>       updateStep(step);
>     },
>   });
>
>   return transactionHash;
> }
> ```

> [tab] React
>
> ```tsx theme={"system"}
> import type { WalletAccount } from '@dynamic-labs-sdk/client';
> import { useDepositToYieldVault } from '@dynamic-labs-sdk/react-hooks';
> import { useState } from 'react';
>
> function DepositButton({
>   vaultId,
>   walletAccount,
>   amount,
> }: {
>   vaultId: string;
>   walletAccount: WalletAccount;
>   amount: string;
> }) {
>   const { mutate: deposit, isPending } = useDepositToYieldVault();
>   const [step, setStep] = useState<'approval' | 'transaction' | null>(null);
>
>   return (
>     <button
>       type="button"
>       onClick={() =>
>         deposit({ vaultId, walletAccount, amount, onStepChange: setStep })
>       }
>       disabled={isPending}
>     >
>       {step === 'approval' && 'Approving...'}
>       {step === 'transaction' && 'Depositing...'}
>       {!step && 'Deposit'}
>     </button>
>   );
> }
> ```

> [tab] React Native
>
> ```tsx theme={"system"}
> import type { WalletAccount } from '@dynamic-labs-sdk/client';
> import { useDepositToYieldVault } from '@dynamic-labs-sdk/react-hooks';
> import { useState } from 'react';
> import { Pressable, Text } from 'react-native';
>
> function DepositButton({
>   vaultId,
>   walletAccount,
>   amount,
> }: {
>   vaultId: string;
>   walletAccount: WalletAccount;
>   amount: string;
> }) {
>   const { mutate: deposit, isPending } = useDepositToYieldVault();
>   const [step, setStep] = useState<'approval' | 'transaction' | null>(null);
>
>   return (
>     <Pressable
>       onPress={() =>
>         deposit({ vaultId, walletAccount, amount, onStepChange: setStep })
>       }
>       disabled={isPending}
>     >
>       <Text>
>         {step === 'approval' && 'Approving...'}
>         {step === 'transaction' && 'Depositing...'}
>         {!step && 'Deposit'}
>       </Text>
>     </Pressable>
>   );
> }
> ```
>
> An embedded wallet signs both steps in a WebView, so keep the screen mounted for the whole deposit. A first deposit into a vault reports `approval` before `transaction`, which on a mobile connection is two waits, not one.

### Solana vaults take several transactions

A Solana deposit or withdrawal is split into an ordered sequence of up to three Solana transactions. The user approves each one in turn, so a single button press can open more than one wallet prompt. `onTransactionStepChange` reports `{ ordinal, total, label }` as each transaction begins, which lets you show "Transaction 1 of 2" instead of an unexplained second prompt.

```typescript theme={"system"}
import { depositToYieldVault, type WalletAccount } from '@dynamic-labs-sdk/client';
import { SolanaYieldStepError } from '@dynamic-labs-sdk/solana';

async function depositToSolanaVault(
  vaultId: string,
  walletAccount: WalletAccount,
  amount: string,
) {
  try {
    const { transactionHash } = await depositToYieldVault({
      vaultId,
      walletAccount,
      amount,
      onTransactionStepChange: ({ ordinal, total }) => {
        // showProgress is your implementation.
        showProgress(`Transaction ${ordinal + 1} of ${total}`);
      },
    });

    return transactionHash;
  } catch (error) {
    if (error instanceof SolanaYieldStepError && error.ordinal > 0) {
      // Earlier transactions already confirmed. Refetch the position and
      // let the user decide, instead of repeating the whole deposit.
    }

    throw error;
  }
}
```

Three behaviors matter for the UI:

* **Keep the user at the screen.** The transactions share one blockhash that stays valid only briefly, so a user who takes too long leaves the remaining transactions expired. Tell users to approve every prompt promptly.
* **A failure can leave the action partly applied.** When `SolanaYieldStepError.ordinal` is greater than zero, the earlier transactions confirmed on-chain. Read the position with `getYieldPosition` before offering a retry.
* **The call resolves after confirmation.** For a Solana vault, `depositToYieldVault` and `withdrawFromYieldVault` resolve only after the last transaction reaches the `confirmed` commitment level, so you can show success without waiting for a separate receipt.

Gas sponsorship works differently for Solana vaults than for EVM vaults. The SDK sponsors an EVM action by default when your project has EVM gas sponsorship enabled and the wallet is a Dynamic embedded wallet. A Solana action is sponsored only when you pass `sponsored: true`, so without it the wallet needs a SOL balance to pay network fees. Because `sponsored: true` rejects when sponsorship is unavailable, and only Dynamic embedded wallets can be sponsored, pass it only when [SVM gas sponsorship](https://docs.dynamic.xyz/docs/javascript/reference/solana/svm-gas-sponsorship.md) is enabled for your project and the wallet is an embedded one:

```typescript theme={"system"}
import { depositToYieldVault, type WalletAccount } from '@dynamic-labs-sdk/client';
import { isWaasWalletAccount } from '@dynamic-labs-sdk/client/waas';
import { isSolanaGasSponsorshipEnabled } from '@dynamic-labs-sdk/solana';

async function depositToSolanaVault(
  vaultId: string,
  walletAccount: WalletAccount,
  amount: string,
) {
  // Leave `sponsored` undefined for an external wallet, which signs and pays its own fees.
  const sponsored =
    isSolanaGasSponsorshipEnabled() && isWaasWalletAccount({ walletAccount })
      ? true
      : undefined;

  return depositToYieldVault({ vaultId, walletAccount, amount, sponsored });
}
```

The same `sponsored` value applies to `withdrawFromYieldVault`.

## Withdrawing

`withdrawFromYieldVault` is the mirror of deposit, minus the approval step. The wallet is redeeming a position it already holds, so an EVM withdrawal is a single transaction. A Solana withdrawal can take up to three, and it takes the same `onTransactionStepChange` callback and raises the same `SolanaYieldStepError` as a Solana deposit.

> [tab] TypeScript
>
> ```typescript theme={"system"}
> import { withdrawFromYieldVault, type WalletAccount } from '@dynamic-labs-sdk/client';
>
> async function withdraw(
>   vaultId: string,
>   walletAccount: WalletAccount,
>   amount: string,
> ) {
>   const { transactionHash } = await withdrawFromYieldVault({
>     vaultId,
>     walletAccount,
>     amount,
>   });
>
>   return transactionHash;
> }
> ```

> [tab] React
>
> ```tsx theme={"system"}
> import type { WalletAccount } from '@dynamic-labs-sdk/client';
> import { useWithdrawFromYieldVault } from '@dynamic-labs-sdk/react-hooks';
>
> function WithdrawButton({
>   vaultId,
>   walletAccount,
>   amount,
> }: {
>   vaultId: string;
>   walletAccount: WalletAccount;
>   amount: string;
> }) {
>   const { mutate: withdraw, isPending } = useWithdrawFromYieldVault();
>
>   return (
>     <button
>       type="button"
>       onClick={() => withdraw({ vaultId, walletAccount, amount })}
>       disabled={isPending}
>     >
>       Withdraw
>     </button>
>   );
> }
> ```

> [tab] React Native
>
> ```tsx theme={"system"}
> import type { WalletAccount } from '@dynamic-labs-sdk/client';
> import { useWithdrawFromYieldVault } from '@dynamic-labs-sdk/react-hooks';
> import { Pressable, Text } from 'react-native';
>
> function WithdrawButton({
>   vaultId,
>   walletAccount,
>   amount,
> }: {
>   vaultId: string;
>   walletAccount: WalletAccount;
>   amount: string;
> }) {
>   const { mutate: withdraw, isPending } = useWithdrawFromYieldVault();
>
>   return (
>     <Pressable
>       onPress={() => withdraw({ vaultId, walletAccount, amount })}
>       disabled={isPending}
>     >
>       <Text>Withdraw</Text>
>     </Pressable>
>   );
> }
> ```

## Rewards

Read accrued rewards with `getYieldRewards`, and only show a claim action once at least one entry has a non-zero `accrued` amount, because `claimYieldRewards` rejects when nothing is claimable.

Not every provider supports rewards, and today only Morpho does. For a vault from an unsupported provider, such as an Aave vault, `getYieldRewards` and `claimYieldRewards` reject with an HTTP 400 error, so check the vault's `provider` from `listYieldVaults` or `getYieldDetails` and hide the rewards section for it. See [Provider support](https://docs.dynamic.xyz/docs/earn/overview.md#provider-support).

> [tab] TypeScript
>
> ```typescript theme={"system"}
> import { claimYieldRewards, getYieldRewards, type WalletAccount } from '@dynamic-labs-sdk/client';
>
> async function claimIfAvailable(vaultId: string, walletAccount: WalletAccount) {
>   const rewards = await getYieldRewards({ vaultId, walletAccount });
>   const claimable = rewards.some((r) => r.accrued && r.accrued !== '0');
>
>   if (!claimable) return null;
>
>   const { transactionHash } = await claimYieldRewards({ vaultId, walletAccount });
>   return transactionHash;
> }
> ```

> [tab] React
>
> ```tsx theme={"system"}
> import type { WalletAccount } from '@dynamic-labs-sdk/client';
> import { useClaimYieldRewards, useGetYieldRewards } from '@dynamic-labs-sdk/react-hooks';
>
> function Rewards({
>   vaultId,
>   walletAccount,
> }: {
>   vaultId: string;
>   walletAccount: WalletAccount | undefined;
> }) {
>   const { data: rewards = [] } = useGetYieldRewards({ vaultId, walletAccount });
>   const { mutate: claim, isPending } = useClaimYieldRewards();
>
>   const claimable = rewards.some((r) => r.accrued && r.accrued !== '0');
>
>   return (
>     <>
>       <ul>
>         {rewards.map((r) => (
>           <li key={r.tokenAddress}>
>             {r.tokenSymbol}: {r.accrued}
>           </li>
>         ))}
>       </ul>
>       <button
>         type="button"
>         disabled={!claimable || !walletAccount || isPending}
>         onClick={() => walletAccount && claim({ vaultId, walletAccount })}
>       >
>         Claim
>       </button>
>     </>
>   );
> }
> ```

> [tab] React Native
>
> ```tsx theme={"system"}
> import type { WalletAccount } from '@dynamic-labs-sdk/client';
> import { useClaimYieldRewards, useGetYieldRewards } from '@dynamic-labs-sdk/react-hooks';
> import { FlatList, Pressable, Text } from 'react-native';
>
> function Rewards({
>   vaultId,
>   walletAccount,
> }: {
>   vaultId: string;
>   walletAccount: WalletAccount | undefined;
> }) {
>   const { data: rewards = [] } = useGetYieldRewards({ vaultId, walletAccount });
>   const { mutate: claim, isPending } = useClaimYieldRewards();
>
>   const claimable = rewards.some((r) => r.accrued && r.accrued !== '0');
>
>   return (
>     <>
>       {rewards.map((reward) => (
>         <Text key={reward.tokenAddress}>
>           {reward.tokenSymbol}: {reward.accrued}
>         </Text>
>       ))}
>       <Pressable
>         disabled={!claimable || !walletAccount || isPending}
>         onPress={() => walletAccount && claim({ vaultId, walletAccount })}
>       >
>         <Text>Claim</Text>
>       </Pressable>
>     </>
>   );
> }
> ```

## Handling errors

| Error / status | Cause | What to do |
| - | - | - |
| `WalletProviderMethodUnavailableError` | The wallet's provider doesn't implement `executeYieldTransaction`, which the built-in EVM and Solana providers do. | Prompt the user to connect or switch to an EVM or Solana wallet before showing Earn actions. |
| Any Earn function rejects with an HTTP 400 error naming `ownerAddress` | The wallet account is on the wrong chain family for the vault, for example an EVM wallet used with a Solana vault. | Pick the wallet account from the vault's `chainName`, as in [Choosing the wallet for a vault](#choosing-the-wallet-for-a-vault). |
| `depositToYieldVault`/`withdrawFromYieldVault` rejects with an HTTP 400 error naming `receiverAddress` | A `receiverAddress` other than the wallet's own was passed for a Solana vault. | Omit `receiverAddress` for Solana vaults. |
| `SolanaYieldStepError` | A transaction in a Solana vault action failed, expired, or was rejected. `code` says which. | Check `ordinal`: when it is above zero, earlier transactions confirmed, so refetch the position before retrying. |
| `depositToYieldVault`/`withdrawFromYieldVault` rejects with a validation error | `amount` has more decimal places than the asset supports. | Round the input to the asset's decimals before submitting. |
| `claimYieldRewards` rejects | Nothing is claimable for this vault right now. | Re-check [`getYieldRewards`](https://docs.dynamic.xyz/docs/javascript/reference/client/get-yield-rewards) and only show the claim action when `accrued` is non-zero. |
| `getYieldRewards` or `claimYieldRewards` rejects with an HTTP 400 error | The vault's provider does not support rewards. | Check the vault's `provider` and hide the rewards section for it. |
| `APIError` with `status === 403` | The vault is private and not scoped to the current environment, or Earn isn't enabled for this environment. | Verify the vault came from your own `listYieldVaults` response rather than a hardcoded ID. |

```js theme={"system"}
import { depositToYieldVault } from '@dynamic-labs-sdk/client';
import { APIError } from '@dynamic-labs-sdk/client/core';

try {
  await depositToYieldVault({ vaultId, walletAccount, amount });
} catch (error) {
  if (error instanceof APIError && error.status === 403) {
    // Vault not accessible to this environment
  }
}
```

## See also

* [Transaction confirmation & simulation](https://docs.dynamic.xyz/docs/javascript/building-ui/transaction-confirmation.md): confirm the underlying transaction
* [Token balances & display](https://docs.dynamic.xyz/docs/javascript/building-ui/token-balances-display.md): show balances alongside vault positions
* [Building a swap UI](https://docs.dynamic.xyz/docs/javascript/building-ui/swap-ui.md): the equivalent flow for token swaps


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.