Skip to main content

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.

Overview

Kamino Earn vaults accept token deposits and automatically allocate them across lending markets. Depositors receive shares representing their portion of the vault, and yield accrues without any manual compounding. This guide walks through integrating Kamino vaults into a Next.js app with Dynamic embedded wallets. For the final code, see the GitHub repository.

How it works

When a user deposits tokens, the Kamino SDK builds a set of instructions that create a share account and mint vault shares proportional to the deposit. Yield accrues as the vault allocates capital to borrowers. When withdrawing, shares are burned and the user receives back the underlying tokens plus earned interest. The SDK returns instruction groups that must each be sent as a separate transaction, confirmed in order:
  • Deposit: depositIxsstakeInFarmIfNeededIxsstakeInFlcFarmIfNeededIxs
  • Withdraw: unstakeFromFarmIfNeededIxswithdrawIxspostWithdrawIxs
Combining groups into one transaction exceeds the 1232-byte Solana limit. This guide shows two approaches for signing and sending these transactions:
ApproachHow it worksRequires
SVM Gas SponsorshipDynamic sponsors the SOL fee; user signs without holding SOLSVM Gas Sponsorship enabled in the dashboard
Standard (no sponsorship)User pays SOL fees from their wallet; all txs signed in one MPC roundSOL balance in the embedded wallet

Setup

Project setup

Follow the React Quickstart using the Custom setup path with Solana. Scaffold a Next.js app with create-next-app.
In the Dynamic dashboard, enable Solana under Chains & Networks, enable Embedded wallets under Wallets, and add your app’s origin under Security → Allowed Origins.

Install dependencies

npm install @kamino-finance/klend-sdk @dynamic-labs-sdk/client @dynamic-labs-sdk/solana @solana/kit @solana/web3.js @tanstack/react-query decimal.js
@kamino-finance/klend-sdk uses WASM internally. Always import it with ssr: false in Next.js to avoid server-side rendering errors.

Environment variables

.env.local
NEXT_PUBLIC_DYNAMIC_ENV_ID=your-environment-id-here
Your environment ID is in the Dynamic dashboard under Developer Settings → SDK & API Keys. The Solana RPC URL is sourced automatically from your Dynamic dashboard network configuration. You can optionally override it with NEXT_PUBLIC_SOLANA_RPC_URL if you prefer a specific endpoint.

Initialize Dynamic

Create src/lib/dynamic.ts:
src/lib/dynamic.ts
import { createDynamicClient, getNetworksData } from "@dynamic-labs-sdk/client";
import { addSolanaExtension } from "@dynamic-labs-sdk/solana";

export const dynamicClient = createDynamicClient({
  environmentId: process.env.NEXT_PUBLIC_DYNAMIC_ENV_ID!,
  metadata: { name: "Kamino Earn" },
});

// Register Solana extension — takes no arguments
addSolanaExtension();

export function getSolanaRpcUrl(): string {
  if (process.env.NEXT_PUBLIC_SOLANA_RPC_URL) {
    return process.env.NEXT_PUBLIC_SOLANA_RPC_URL;
  }
  const networks = getNetworksData(dynamicClient);
  const solana = networks.find((n) => n.chain === "SOL");
  const url = solana?.rpcUrls.http[0];
  if (!url) {
    throw new Error(
      "No Solana RPC URL found. Set NEXT_PUBLIC_SOLANA_RPC_URL or add a Solana network in your Dynamic dashboard."
    );
  }
  return url;
}

Set up wallet context

Create src/lib/providers.tsx to expose the connected Solana account to the rest of the app:
src/lib/providers.tsx
"use client";

import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
import { getWalletAccounts, onEvent, isSignedIn, logout, detectSocialRedirectUrl, completeSocialRedirect } from "@dynamic-labs-sdk/client";
import { createWaasWalletAccounts } from "@dynamic-labs-sdk/client/waas";
import { isSolanaWalletAccount, type SolanaWalletAccount } from "@dynamic-labs-sdk/solana";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { dynamicClient } from "./dynamic";

const WalletContext = createContext<{
  solanaAccount: SolanaWalletAccount | null;
  loggedIn: boolean;
  refresh: () => void;
  disconnect: () => Promise<void>;
}>({ solanaAccount: null, loggedIn: false, refresh: () => {}, disconnect: async () => {} });

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

const queryClient = new QueryClient();

export default function Providers({ children }: { children: ReactNode }) {
  const [solanaAccount, setSolanaAccount] = useState<SolanaWalletAccount | null>(null);
  const [loggedIn, setLoggedIn] = useState(false);

  const refresh = useCallback(() => {
    const accounts = getWalletAccounts(dynamicClient);
    setSolanaAccount(accounts.find(isSolanaWalletAccount) ?? null);
    setLoggedIn(isSignedIn(dynamicClient));
  }, []);

  const ensureSolanaWallet = useCallback(async () => {
    try {
      const accounts = getWalletAccounts(dynamicClient);
      if (!accounts.some(isSolanaWalletAccount) && isSignedIn(dynamicClient)) {
        await createWaasWalletAccounts({ chains: ["SOL"] }, dynamicClient);
      }
    } catch { /* wallet may already exist */ }
    refresh();
  }, [refresh]);

  useEffect(() => {
    const unsubWallets = onEvent({ event: "walletAccountsChanged", listener: () => ensureSolanaWallet() }, dynamicClient);
    const unsubLogout = onEvent({ event: "logout", listener: () => { setSolanaAccount(null); setLoggedIn(false); } }, dynamicClient);
    refresh();
    return () => { unsubWallets(); unsubLogout(); };
  }, [refresh, ensureSolanaWallet]);

  return (
    <WalletContext.Provider value={{ solanaAccount, loggedIn, refresh, disconnect: async () => { await logout(dynamicClient); setSolanaAccount(null); setLoggedIn(false); } }}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </WalletContext.Provider>
  );
}

Fetching vault data

Create src/lib/kamino.ts to load vaults and metrics from the Kamino REST API:
src/lib/kamino.ts
import type { KaminoVault, VaultMetrics, UserPosition } from "./types";

const KAMINO_API_BASE = "https://api.kamino.finance";

export async function fetchVaults(): Promise<KaminoVault[]> {
  const res = await fetch(`${KAMINO_API_BASE}/kvaults/vaults`, { next: { revalidate: 60 } });
  if (!res.ok) throw new Error(`Failed to fetch vaults: ${res.statusText}`);
  const vaults: KaminoVault[] = await res.json();

  // Filter to vaults with deployed funds, sort by AUM descending
  return vaults
    .filter((v) => parseFloat(v.state.prevAum ?? "0") > 1000)
    .sort((a, b) => parseFloat(b.state.prevAum ?? "0") - parseFloat(a.state.prevAum ?? "0"));
}

export async function fetchVaultMetrics(vaultAddress: string): Promise<VaultMetrics> {
  const res = await fetch(`${KAMINO_API_BASE}/kvaults/vaults/${vaultAddress}/metrics`, { next: { revalidate: 30 } });
  if (!res.ok) throw new Error(`Failed to fetch vault metrics: ${res.statusText}`);
  const d = await res.json();

  return {
    apy: parseFloat(d.apy) || 0,
    apy7d: parseFloat(d.apy7d) || 0,
    apy30d: parseFloat(d.apy30d) || 0,
    tvlUsd: (parseFloat(d.tokensAvailableUsd) || 0) + (parseFloat(d.tokensInvestedUsd) || 0),
    tokenPrice: parseFloat(d.tokenPrice) || 0,
    numberOfHolders: d.numberOfHolders || 0,
    sharesIssued: d.sharesIssued || "0",
    cumulativeInterestEarnedUsd: parseFloat(d.cumulativeInterestEarnedUsd) || 0,
  };
}

export async function fetchUserPositions(userAddress: string): Promise<UserPosition[]> {
  const res = await fetch(`${KAMINO_API_BASE}/kvaults/users/${userAddress}/positions`);
  if (!res.ok) {
    if (res.status === 404) return [];
    throw new Error(`Failed to fetch user positions: ${res.statusText}`);
  }
  const data = await res.json();
  // API returns either an array or { positions: [...] }
  const rows: Record<string, unknown>[] = Array.isArray(data) ? data : (data.positions ?? []);
  return rows.map((r) => ({
    vaultAddress: (r.vaultAddress ?? r.vault ?? "") as string,
    shares: String(r.totalShares ?? r.shares ?? "0"),
    tokenBalance: Number(r.tokenBalance ?? r.balance ?? 0),
    usdValue: Number(r.usdValue ?? r.balanceUsd ?? 0),
  }));
}

Deposit and withdraw

Create src/lib/useVaultOperations.ts. The shared prepareTransaction helper builds one unsigned VersionedTransaction per instruction group. The signing strategy differs between the two approaches.

Transaction preparation (shared)

Both approaches use the same prepareTransaction helper:
import { Connection, VersionedTransaction, SendTransactionError } from "@solana/web3.js";
import {
  createSolanaRpc, createNoopSigner, address, pipe,
  createTransactionMessage, setTransactionMessageFeePayerSigner,
  setTransactionMessageLifetimeUsingBlockhash, appendTransactionMessageInstructions,
  partiallySignTransactionMessageWithSigners, getBase64EncodedWireTransaction,
  type TransactionSigner,
} from "@solana/kit";
import { getSolanaRpcUrl } from "./dynamic";

interface PreparedTransaction {
  unsigned: VersionedTransaction;
  blockhash: string;
  lastValidBlockHeight: bigint;
}

// Build one unsigned transaction. Uses finalized commitment to avoid
// "Blockhash not found" errors during preflight simulation.
async function prepareTransaction(
  instructions: Parameters<typeof appendTransactionMessageInstructions>[0],
  noopSigner: TransactionSigner
): Promise<PreparedTransaction> {
  const rpc = createSolanaRpc(getSolanaRpcUrl());
  const { value: latestBlockhash } = await rpc
    .getLatestBlockhash({ commitment: "finalized" })
    .send();

  const txMessage = pipe(
    createTransactionMessage({ version: 0 }),
    (tx) => setTransactionMessageFeePayerSigner(noopSigner, tx),
    (tx) => setTransactionMessageLifetimeUsingBlockhash(
      { blockhash: latestBlockhash.blockhash, lastValidBlockHeight: latestBlockhash.lastValidBlockHeight },
      tx
    ),
    (tx) => appendTransactionMessageInstructions(instructions, tx)
  );

  const compiled = await partiallySignTransactionMessageWithSigners(txMessage);
  const wireBase64 = getBase64EncodedWireTransaction(compiled);
  return {
    unsigned: VersionedTransaction.deserialize(Buffer.from(wireBase64, "base64")),
    blockhash: latestBlockhash.blockhash,
    lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
  };
}

With SVM Gas Sponsorship (gasless)

Enable SVM Gas Sponsorship in the Dynamic dashboard under Settings → Embedded Wallets. This is available exclusively for V3 MPC embedded wallets.
Dynamic sponsors the SOL transaction fee so users don’t need a SOL balance. signAndSendSponsoredTransaction handles signing, fee-payer replacement, and broadcasting in one call. Transactions are sent one at a time since each is independently sponsored.
src/lib/useVaultOperations.ts
"use client";

import { useState } from "react";
import {
  signAndSendSponsoredTransaction,
  SponsorTransactionError,
  type SolanaWalletAccount,
} from "@dynamic-labs-sdk/solana";
import { Connection, VersionedTransaction, SendTransactionError } from "@solana/web3.js";
import {
  createSolanaRpc, createNoopSigner, address,
  type TransactionSigner,
} from "@solana/kit";
import { KaminoVault, type DepositIxs, type WithdrawIxs } from "@kamino-finance/klend-sdk";
import { Decimal } from "decimal.js";
import { useWallet } from "./providers";
import { dynamicClient, getSolanaRpcUrl } from "./dynamic";

// ... prepareTransaction helper from above ...

async function signSendAndConfirm(
  tx: VersionedTransaction,
  blockhash: string,
  lastValidBlockHeight: bigint,
  walletAccount: SolanaWalletAccount
): Promise<string> {
  try {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const { signature } = await signAndSendSponsoredTransaction(
      { transaction: tx as any, walletAccount },
      dynamicClient
    );
    const connection = new Connection(getSolanaRpcUrl(), "confirmed");
    const result = await connection.confirmTransaction(
      { signature, blockhash, lastValidBlockHeight: Number(lastValidBlockHeight) },
      "confirmed"
    );
    if (result.value.err) {
      throw new Error(`Transaction failed: ${JSON.stringify(result.value.err)}`);
    }
    return signature;
  } catch (err) {
    if (err instanceof SponsorTransactionError) {
      throw new Error(
        "Gas sponsorship failed. Enable SVM Gas Sponsorship in your Dynamic dashboard under Settings → Embedded Wallets."
      );
    }
    if (err instanceof SendTransactionError) {
      const connection = new Connection(getSolanaRpcUrl(), "confirmed");
      const logs = await err.getLogs(connection);
      throw new Error(
        err.message + (logs?.length ? `\n\nLogs:\n${logs.join("\n")}` : "")
      );
    }
    throw err;
  }
}

export function useVaultOperations() {
  const { solanaAccount } = useWallet();
  const [isOperating, setIsOperating] = useState(false);
  const [operationError, setOperationError] = useState<string | null>(null);

  const executeDeposit = async (vaultAddress: string, amount: number): Promise<string | undefined> => {
    if (!solanaAccount) return;
    setIsOperating(true);
    setOperationError(null);
    try {
      const rpc = createSolanaRpc(getSolanaRpcUrl());
      const noopSigner = createNoopSigner(address(solanaAccount.address));
      const vault = new KaminoVault(rpc, address(vaultAddress));
      const depositIxs: DepositIxs = await vault.depositIxs(noopSigner, new Decimal(amount));

      const groups = [
        depositIxs.depositIxs,
        depositIxs.stakeInFarmIfNeededIxs,
        depositIxs.stakeInFlcFarmIfNeededIxs,
      ].filter((g) => g.length > 0);

      if (groups.length === 0) return;

      // Build all transactions in parallel, then sponsor and confirm each sequentially.
      const prepared = await Promise.all(groups.map((g) => prepareTransaction(g, noopSigner)));

      let lastHash: string | undefined;
      for (const { unsigned, blockhash, lastValidBlockHeight } of prepared) {
        lastHash = await signSendAndConfirm(unsigned, blockhash, lastValidBlockHeight, solanaAccount);
      }
      return lastHash;
    } catch (err) {
      setOperationError(err instanceof Error ? err.message : "Deposit failed");
      throw err;
    } finally {
      setIsOperating(false);
    }
  };

  const executeWithdraw = async (vaultAddress: string, shares: number): Promise<string | undefined> => {
    if (!solanaAccount) return;
    setIsOperating(true);
    setOperationError(null);
    try {
      const rpc = createSolanaRpc(getSolanaRpcUrl());
      const noopSigner = createNoopSigner(address(solanaAccount.address));
      const vault = new KaminoVault(rpc, address(vaultAddress));
      const withdrawIxs: WithdrawIxs = await vault.withdrawIxs(noopSigner, new Decimal(shares));

      const groups = [
        withdrawIxs.unstakeFromFarmIfNeededIxs,
        withdrawIxs.withdrawIxs,
        withdrawIxs.postWithdrawIxs,
      ].filter((g) => g.length > 0);

      if (groups.length === 0) return;

      const prepared = await Promise.all(groups.map((g) => prepareTransaction(g, noopSigner)));

      let lastHash: string | undefined;
      for (const { unsigned, blockhash, lastValidBlockHeight } of prepared) {
        lastHash = await signSendAndConfirm(unsigned, blockhash, lastValidBlockHeight, solanaAccount);
      }
      return lastHash;
    } catch (err) {
      setOperationError(err instanceof Error ? err.message : "Withdraw failed");
      throw err;
    } finally {
      setIsOperating(false);
    }
  };

  return { isOperating, operationError, executeDeposit, executeWithdraw };
}

Without gas sponsorship

When SVM Gas Sponsorship is not enabled, users pay Solana transaction fees from their wallet SOL balance. All transactions are built in parallel and signed in a single MPC round with signAllTransactions, then sent and confirmed sequentially. This minimizes round-trips to the MPC network.
src/lib/useVaultOperations.ts
"use client";

import { useState } from "react";
import { signAllTransactions, type SolanaWalletAccount } from "@dynamic-labs-sdk/solana";
import { Connection, VersionedTransaction, SendTransactionError } from "@solana/web3.js";
import {
  createSolanaRpc, createNoopSigner, address,
  type TransactionSigner,
} from "@solana/kit";
import { KaminoVault, type DepositIxs, type WithdrawIxs } from "@kamino-finance/klend-sdk";
import { Decimal } from "decimal.js";
import { useWallet } from "./providers";
import { dynamicClient, getSolanaRpcUrl } from "./dynamic";

// ... prepareTransaction helper from above ...

async function sendAndConfirm(
  tx: VersionedTransaction,
  blockhash: string,
  lastValidBlockHeight: bigint
): Promise<string> {
  const connection = new Connection(getSolanaRpcUrl(), "confirmed");
  let txHash: string;
  try {
    txHash = await connection.sendRawTransaction(tx.serialize(), {
      skipPreflight: false,
      preflightCommitment: "confirmed",
    });
  } catch (err) {
    if (err instanceof SendTransactionError) {
      const logs = err.logs ?? [];
      if (logs.find((l) => l.includes("insufficient lamports"))) {
        throw new Error("Insufficient SOL balance. Add more SOL to your wallet and try again.");
      }
      throw new Error(logs.join("\n") || err.message);
    }
    throw err;
  }
  await connection.confirmTransaction(
    { signature: txHash, blockhash, lastValidBlockHeight: Number(lastValidBlockHeight) },
    "confirmed"
  );
  return txHash;
}

export function useVaultOperations() {
  const { solanaAccount } = useWallet();
  const [isOperating, setIsOperating] = useState(false);
  const [operationError, setOperationError] = useState<string | null>(null);

  const executeDeposit = async (vaultAddress: string, amount: number): Promise<string | undefined> => {
    if (!solanaAccount) return;
    setIsOperating(true);
    setOperationError(null);
    try {
      const rpc = createSolanaRpc(getSolanaRpcUrl());
      const noopSigner = createNoopSigner(address(solanaAccount.address));
      const vault = new KaminoVault(rpc, address(vaultAddress));
      const depositIxs: DepositIxs = await vault.depositIxs(noopSigner, new Decimal(amount));

      const groups = [
        depositIxs.depositIxs,
        depositIxs.stakeInFarmIfNeededIxs,
        depositIxs.stakeInFlcFarmIfNeededIxs,
      ].filter((g) => g.length > 0);

      if (groups.length === 0) return;

      // Build all transactions in parallel, sign in one MPC round, confirm sequentially.
      const prepared = await Promise.all(groups.map((g) => prepareTransaction(g, noopSigner)));
      const { signedTransactions } = await signAllTransactions(
        { transactions: prepared.map((p) => p.unsigned), walletAccount: solanaAccount },
        dynamicClient
      );

      let lastHash: string | undefined;
      for (let i = 0; i < signedTransactions.length; i++) {
        const { blockhash, lastValidBlockHeight } = prepared[i];
        lastHash = await sendAndConfirm(signedTransactions[i] as VersionedTransaction, blockhash, lastValidBlockHeight);
      }
      return lastHash;
    } catch (err) {
      setOperationError(err instanceof Error ? err.message : "Deposit failed");
      throw err;
    } finally {
      setIsOperating(false);
    }
  };

  const executeWithdraw = async (vaultAddress: string, shares: number): Promise<string | undefined> => {
    if (!solanaAccount) return;
    setIsOperating(true);
    setOperationError(null);
    try {
      const rpc = createSolanaRpc(getSolanaRpcUrl());
      const noopSigner = createNoopSigner(address(solanaAccount.address));
      const vault = new KaminoVault(rpc, address(vaultAddress));
      const withdrawIxs: WithdrawIxs = await vault.withdrawIxs(noopSigner, new Decimal(shares));

      const groups = [
        withdrawIxs.unstakeFromFarmIfNeededIxs,
        withdrawIxs.withdrawIxs,
        withdrawIxs.postWithdrawIxs,
      ].filter((g) => g.length > 0);

      if (groups.length === 0) return;

      const prepared = await Promise.all(groups.map((g) => prepareTransaction(g, noopSigner)));
      const { signedTransactions } = await signAllTransactions(
        { transactions: prepared.map((p) => p.unsigned), walletAccount: solanaAccount },
        dynamicClient
      );

      let lastHash: string | undefined;
      for (let i = 0; i < signedTransactions.length; i++) {
        const { blockhash, lastValidBlockHeight } = prepared[i];
        lastHash = await sendAndConfirm(signedTransactions[i] as VersionedTransaction, blockhash, lastValidBlockHeight);
      }
      return lastHash;
    } catch (err) {
      setOperationError(err instanceof Error ? err.message : "Withdraw failed");
      throw err;
    } finally {
      setIsOperating(false);
    }
  };

  return { isOperating, operationError, executeDeposit, executeWithdraw };
}

Wiring it together

The main page loads vaults with useQuery and disables SSR on the VaultsInterface component to avoid WASM errors:
src/app/page.tsx
"use client";

import dynamic from "next/dynamic";

// Dynamically import to avoid WASM-related SSR issues from klend-sdk
const VaultsInterface = dynamic(
  () => import("@/components/VaultsInterface").then((m) => m.VaultsInterface),
  { ssr: false }
);

export default function Main() {
  return <VaultsInterface />;
}
Inside VaultsInterface, vaults are paginated (6 per page) and metrics are fetched only for the current page. After an action completes, all transactions are already confirmed on-chain. Positions are refetched immediately and vault metrics are invalidated. Multiple retries at increasing intervals handle Kamino’s API indexing lag (can be up to ~15s):
const queryClient = useQueryClient();

const refreshAfterAction = () => {
  refetchPositions();
  queryClient.invalidateQueries({ queryKey: ["kamino-vault-metrics"] });
  for (const delay of [3000, 7000, 12000, 20000]) {
    setTimeout(() => refetchPositions(), delay);
  }
};
handleDeposit and handleWithdraw return Promise<boolean> so VaultCard can clear its input field only on success:
onClick={async () => {
  const n = parseFloat(depositAmount);
  if (!isNaN(n) && n > 0) {
    const ok = await onDeposit(vault.address, n);
    if (ok) setDepositAmount("");
  }
}}

Run the app

npm run dev
Add http://localhost:3000 to your allowed origins in the Dynamic dashboard under Developer Settings → CORS Origins. If you’re using SVM Gas Sponsorship, also enable it in Settings → Embedded Wallets → SVM Gas Sponsorship.

Full source code

GitHub repository →

Additional resources