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

# 如何使用 Moralis API 构建投资组合查看器

在构建 web3 应用时，应用必须展示与已连接钱包相关的一些信息，例如 ERC20 代币余额、ERC721 代币余额、交易历史等等……

Moralis 为最常见的功能提供了广泛的开箱即用 API，如钱包原生余额、获取所有 NFT 和收藏品、钱包年龄和活动等等……

在本指南中，您将学习如何使用 Moralis API 和 NextJS 为已连接的钱包构建一个简单的 ERC20 代币余额视图。

<Note>
  如果您希望在构建之前先试用该投资组合查看器，可以[在这里](https://portfolio-dashboard-example.vercel.app)试用。
</Note>

<img src="https://mintcdn.com/monadfoundation-40611fb6/5Mt9_Scj9fq4fC68/static/img/guides/moralis-api-guide/1.png?fit=max&auto=format&n=5Mt9_Scj9fq4fC68&q=85&s=e56ed98cbf9451165e6f6ed214321c30" alt="portfolio view" width="2020" height="1460" data-path="static/img/guides/moralis-api-guide/1.png" />

## 要求

在开始之前，您需要：

1. **Moralis API 密钥**：从 [Moralis 仪表板](https://admin.moralis.io/)获取
2. **Reown Project ID**：从 [Reown Cloud](https://cloud.reown.com/)获取
   * 您也可以查看 [Monad 的 Reown AppKit 指南](http://docs.monad.xyz/guides/reown-guide)

## 步骤 1：初始化项目

### 创建 Nextjs 应用

```
npx create-next-app@latest portfolio-app --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
```

### 安装 ShadCN 组件

```
npx shadcn@latest add button card tabs
```

### 安装 Tanstack React Query（用于更好的异步状态管理）

```
npm install @tanstack/react-query
```

## 步骤 2：环境设置

将您的 Moralis API 密钥和 Reown Project ID 添加到 `.env.local`：

```bash theme={null}
MORALIS_API_KEY=
NEXT_PUBLIC_PROJECT_ID=
```

## 步骤 3：连接钱包功能

### 创建 `ContextProvider` 组件

在 `src` 目录下创建一个名为 `context` 的文件夹，在该文件夹内创建一个名为 `index.ts` 的文件。

```ts title="src/context/index.ts" theme={null}
"use client";

import { wagmiAdapter, projectId } from "@/config";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createAppKit } from "@reown/appkit/react";
import { monad } from "@reown/appkit/networks";
import React, { type ReactNode } from "react";
import { WagmiProvider, type Config } from "wagmi";

// Set up queryClient
const queryClient = new QueryClient();

if (!projectId) {
  throw new Error("NEXT_PUBLIC_PROJECT_ID is not set");
}

// Set up metadata
const metadata = {
  name: "Portfolio Viewer",
  description: "View your crypto portfolio on Monad blockchain",
  url: "your-app-url",
  icons: ["your-app-icon"],
};

// Create the modal
const modal = createAppKit({
  adapters: [wagmiAdapter],
  projectId,
  networks: [monad],
  defaultNetwork: monad,
  metadata: metadata,
  features: {
    analytics: true,
  },
});

export function ContextProvider({ children }: { children: ReactNode }) {
  return (
    <WagmiProvider config={wagmiAdapter.wagmiConfig as Config}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </WagmiProvider>
  );
}
```

### 在 `layout.tsx` 中用 `ContextProvider` 组件包裹应用

```tsx title="src/app/layout.tsx" theme={null}
// imports

export const metadata: Metadata = {
  title: "Monad Portfolio App",
  description: "View your crypto portfolio on Monad blockchain",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" className={GeistSans.variable} style={{
      // @ts-ignore
      '--font-geist-sans': GeistSans.style.fontFamily,
      '--font-geist-mono': GeistMono.style.fontFamily,
    }}>
      <body className="font-sans min-h-screen flex flex-col bg-dot-pattern">
        <ContextProvider>
          <main className="flex-1 pb-6">{children}</main>
        </ContextProvider>
      </body>
    </html>
  );
}
```

### 创建自定义 `ConnectButton`

在 `components/wallet` 文件夹中创建一个名为 `ConnectButton.tsx` 的文件

```tsx title="src/components/wallet/ConnectButton.tsx" theme={null}
"use client";

import { useAppKit, useAppKitAccount, useDisconnect } from "@reown/appkit/react";
import { Button } from "@/components/ui/Button";
import { ConnectIcon, type ConnectIconHandle } from "@/components/ui/ConnectIcon";

export function ConnectButton() {
  const { open } = useAppKit();
  const { address, isConnected } = useAppKitAccount();
  const { disconnect } = useDisconnect();

  if (isConnected && address) {
    return (
      <div className="inline-flex rounded-md shadow-sm">
        <a
          href={`https://monadvision.com/address/${address}`}
          target="_blank"
          rel="noopener noreferrer"
          className="inline-flex items-center px-8 h-10 bg-primary text-primary-foreground rounded-l-md font-semibold text-sm hover:bg-primary/90 transition-colors"
        >
          {formatAddress(address)}
        </a>
        <Button
          onClick={() => disconnect()}
          size="icon"
          className="h-10 w-10 rounded-l-none border-l border-primary-foreground/20"
        >
          <ConnectIcon size={16} />
        </Button>
      </div>
    );
  }

  return (
    <Button
      onClick={() => open()}
      size="lg"
      className="font-semibold shadow-sm gap-2"
    >
      <ConnectIcon ref={connectIconRef} size={20} />
      Connect Wallet
    </Button>
  );
}
```

我们使用 `useAppKit` 和 `useAppKitAccount` hook 创建自定义连接按钮，而不是使用 Reown AppKit 提供的标准按钮。

### 创建带有自定义 `ConnectButton` 的 `Header` 组件

<img src="https://mintcdn.com/monadfoundation-40611fb6/5Mt9_Scj9fq4fC68/static/img/guides/moralis-api-guide/2.png?fit=max&auto=format&n=5Mt9_Scj9fq4fC68&q=85&s=5c0d29a01608b83e09c38136855b2b4f" alt="header" width="1994" height="680" data-path="static/img/guides/moralis-api-guide/2.png" />

在 `src/components/layout` 文件夹中创建一个名为 `Header.tsx` 的文件，并将新创建的 `ConnectButton` 组件导入其中。

```tsx title="src/components/layout/Header.tsx" theme={null}
"use client";

import { ConnectButton } from "../wallet/ConnectButton";
import { ExternalLink } from "lucide-react";

export function Header() {
  return (
    <header className="sticky top-0 z-50 w-full border-b border-border bg-card/80 backdrop-blur supports-[backdrop-filter]:bg-card/60">
        <div className="flex h-16 items-center justify-between">
          <div className="flex items-center gap-3">
            <h1 className="text-xl font-bold text-foreground">
              Portfolio App
            </h1>
          </div>
          <div className="flex items-center gap-6">
            <ConnectButton />
          </div>
        </div>
    </header>
  );
}
```

在 `layout.tsx` 中导入 `Header` 组件

```tsx title="src/app/layout.tsx" theme={null}
import { Header } from "@/components/layout/Header";

// rest of the code

<ContextProvider>
    <Header />
    <main className="flex-1 pb-6">{children}</main>
</ContextProvider>

// rest of the code
```

您现在应该能够看到一个自定义的 Connect Wallet 按钮了！

## 步骤 4：获取已连接钱包的代币余额

### 创建 `/api/wallet/balances` 路由

在文件夹 `src/app/api/wallet/balances/` 中创建一个名为 `route.ts` 的文件

我们使用 "Get Native & ERC20 Token Balances by Wallet" Moralis API 端点来获取代币余额。您可以在[这里](https://docs.moralis.com/web3-data-api/evm/reference/wallet-api/get-wallet-token-balances-price?address=0xcB1C1FdE09f811B294172696404e88E658659905\&chain=0x8f\&token_addresses=\[]\&limit=25)找到该端点的文档。

它接收一个地址和链 ID，并返回该地址的 ERC20 代币列表（包括 Logo、名称、符号）和原生余额。

```ts title="src/app/api/wallet/balances/route.ts" theme={null}
import { NextRequest, NextResponse } from "next/server";

const MORALIS_API_BASE = "https://deep-index.moralis.io/api/v2.2";

export async function GET(request: NextRequest) {
  try {
    const { searchParams } = request.nextUrl;
    const address = searchParams.get("address");
    const chain = "0x8f"; // 0x8f = 143 (Monad chain ID in hex)

    if (!address) {
      return NextResponse.json(
        { error: "Address is required" },
        { status: 400 }
      );
    }

    // Fetch ERC20 token balances with price data from Moralis
    const data = await moralisRequest({
      endpoint: `/wallets/${address}/tokens`,
      params: {
        chain,
      },
    });

    return NextResponse.json(data);
  } catch (error) {
    console.error("Error fetching token balances:", error);

    const errorMessage = error instanceof Error ? error.message : "Failed to fetch token balances";

    return NextResponse.json(
      {
        error: errorMessage,
      },
      { status: 500 }
    );
  }
}

async function moralisRequest({
  endpoint,
  params,
}) {
  const apiKey = process.env.MORALIS_API_KEY;

  if (!apiKey) {
    throw new Error("MORALIS_API_KEY is not set");
  }

  const url = new URL(`${MORALIS_API_BASE}${endpoint}`);

  if (params) {
    Object.entries(params).forEach(([key, value]) => {
      url.searchParams.append(key, String(value));
    });
  }

  const response = await fetch(url.toString(), {
    method: "GET",
    headers: {
      "X-API-Key": apiKey,
      "Content-Type": "application/json",
    },
    next: {
      revalidate: 30, // Cache for 30 seconds
    },
  });

  if (!response.ok) {
    const errorData = await response.json().catch(() => ({}));
    const errorMessage = errorData.message || `Moralis API error: ${response.status}`;

    console.error("Moralis API Error:", {
      status: response.status,
      url: url.toString(),
      error: errorData,
    });

    throw new Error(errorMessage);
  }

  return response.json();
}
```

### 创建名为 `useTokenBalances` 的 React hook

这个 React hook 调用您创建的 `/api/wallet/balances/` 端点获取代币余额数据，将其格式化为合适的列表，并使其可在前端使用。

```ts title="src/hooks/useTokenBalances.ts" theme={null}
"use client";

import { useQuery } from "@tanstack/react-query";

async function fetchTokenBalances(
  address: string,
) {
  const response = await fetch(
    `/api/wallet/balances?address=${address}`
  );

  if (!response.ok) {
    const error = await response.json().catch(() => ({}));
    throw new Error(error.error || "Failed to fetch token balances");
  }

  const data = await response.json();

  // Check if data exists
  if (!data) {
    return [];
  }

  // Handle both response formats: direct array or object with result property
  const tokens = Array.isArray(data) ? data : data.result;

  if (!tokens || !Array.isArray(tokens)) {
    return [];
  }

  // Transform Moralis data to our Token type
  return tokens.map((token) => {
    const balanceFormatted = parseFloat(
      formatTokenBalance(token.balance, token.decimals)
    );

    // Calculate USD value if we have price but not value
    const usdValue = token.usd_value !== undefined
      ? token.usd_value
      : token.usd_price !== undefined
        ? token.usd_price * balanceFormatted
        : undefined;

    return {
      address: token.token_address,
      name: token.name,
      symbol: token.symbol,
      logo: token.logo || token.thumbnail,
      decimals: token.decimals,
      balance: token.balance,
      balanceFormatted,
      usdPrice: token.usd_price,
      usdValue,
      isNative: token.native_token || false,
      isSpam: token.possible_spam,
      verified: token.verified_contract,
    };
  });
}

export function useTokenBalances(address?: string, enabled: boolean = true) {
  return useQuery({
    queryKey: ["tokenBalances", address],
    queryFn: () => fetchTokenBalances(address!),
    enabled: enabled && !!address,
    staleTime: 30000, // 30 seconds
  });
}

function formatTokenBalance(
  balance: string | number,
  decimals: number = 18
): string {
  const balanceNum = typeof balance === "string" ? parseFloat(balance) : balance;
  const divisor = Math.pow(10, decimals);
  const formattedBalance = balanceNum / divisor;

  // Show more decimals for small amounts
  if (formattedBalance < 0.01) {
    return formattedBalance.toFixed(6);
  } else if (formattedBalance < 1) {
    return formattedBalance.toFixed(4);
  } else {
    return formattedBalance.toFixed(2);
  }
}
```

我们稍后将在 UI 组件中使用此 hook

## 步骤 5：创建 UI

### 创建 `TokenRow` 组件

<img src="https://mintcdn.com/monadfoundation-40611fb6/5Mt9_Scj9fq4fC68/static/img/guides/moralis-api-guide/3.png?fit=max&auto=format&n=5Mt9_Scj9fq4fC68&q=85&s=9825048fa2647166a1ae4f83369ee584" alt="token-row" width="1942" height="576" data-path="static/img/guides/moralis-api-guide/3.png" />

`TokenRow` 组件将显示代币详情，例如名称、符号、logo、美元价格、已连接钱包中的代币数量以及美元价值。

在文件夹 `src/components/portfolio/tokens/` 中创建一个名为 `TokenRow.tsx` 的文件

```tsx title="src/components/portfolio/tokens/TokenRow.tsx" theme={null}
import Image from "next/image";

export function TokenRow({ token }) {
  return (
    <div className="grid grid-cols-12 gap-4 px-6 py-4 hover:bg-muted/30 transition-colors">
      {/* Token Info */}
      <div className="col-span-4 flex items-center gap-3">
        {token.logo ? (
          <Image
            src={token.logo}
            alt={token.name}
            width={40}
            height={40}
            className="rounded-full"
            onError={(e) => {
              e.currentTarget.style.display = "none";
            }}
          />
        ) : (
          <div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold border border-primary/20">
            {token.symbol.charAt(0)}
          </div>
        )}
        <div className="min-w-0">
          <div className="flex items-center gap-2">
            <p className="font-semibold truncate">{token.symbol}</p>
          </div>
          <p className="text-sm text-muted-foreground truncate">
            {token.name}
          </p>
        </div>
      </div>

      {/* Price */}
      <div className="col-span-2 flex items-center justify-end">
        {token.usdPrice !== undefined && token.usdPrice > 0 ? (
          <p className="text-sm font-medium">
            ${formatCurrency(token.usdPrice, 4)}
          </p>
        ) : (
          <p className="text-sm text-muted-foreground">-</p>
        )}
      </div>

      {/* Amount */}
      <div className="col-span-3 flex items-center justify-end">
        <p className="font-medium">{formatCurrency(token.balanceFormatted, 2)}</p>
      </div>

      {/* USD Value */}
      <div className="col-span-3 flex items-center justify-end">
        {token.usdValue !== undefined && token.usdValue > 0 ? (
          <p className="font-semibold text-primary">
            {formatUSD(token.usdValue)}
          </p>
        ) : (
          <p className="text-sm text-muted-foreground">-</p>
        )}
      </div>
    </div>
  );
}

// Helper function to format values in the UI

function formatUSD(value: number): string {
  if (value === 0) return "$0.00";
  if (value < 0.01) return "< $0.01";
  if (value < 1) return `$${value.toFixed(4)}`;
  if (value < 1000) return `$${value.toFixed(2)}`;
  if (value < 1000000) return `$${(value / 1000).toFixed(2)}K`;
  return `$${(value / 1000000).toFixed(2)}M`;
}

function formatCurrency(value: number, decimals: number = 2): string {
  return value.toLocaleString("en-US", {
    minimumFractionDigits: decimals,
    maximumFractionDigits: decimals,
  });
}
```

### 创建 `TokenList` 组件

<img src="https://mintcdn.com/monadfoundation-40611fb6/5Mt9_Scj9fq4fC68/static/img/guides/moralis-api-guide/4.png?fit=max&auto=format&n=5Mt9_Scj9fq4fC68&q=85&s=9d00b030ffd2450fb0a6d51cc10935a8" alt="token-list" width="1950" height="604" data-path="static/img/guides/moralis-api-guide/4.png" />

在 `src/components/portfolio` 文件夹中创建 `TokenList.tsx` 文件，并将新创建的 `TokenRow.tsx` 组件导入其中。

```tsx title="src/components/portfolio/TokenList.tsx" theme={null}
import { TokenRow } from "./tokens/TokenRow";

export function TokenList({
  tokens,
  isLoading,
  error,
  showLowValueTokens = false,
}) {

  if (isLoading) {
    return (
      // Loading state
    );
  }

  if (error) {
    return (
      // Error state
    );
  }

  
  if (tokens.length === 0) {
    return (
        // Empty state 
    );
  }

  return (
    <div className="divide-y divide-border">
        {tokens.map((token) => (
            <TokenRow key={token.address} token={token} />
        ))}
    </div>
  );
}
```

### 创建 `PortfolioDashboard` 组件

<img src="https://mintcdn.com/monadfoundation-40611fb6/5Mt9_Scj9fq4fC68/static/img/guides/moralis-api-guide/5.png?fit=max&auto=format&n=5Mt9_Scj9fq4fC68&q=85&s=0834bd930f73bf2e36dee0251350b5be" alt="dashboard" width="1994" height="846" data-path="static/img/guides/moralis-api-guide/5.png" />

`PortfolioDashboard` 组件将显示 `TokenList`，并可以进一步修改以包含更多组件，展示您希望用户看到的信息。

```tsx title="src/components/portfolio/PortfolioDashboard.tsx" theme={null}
"use client";

import { useState, useEffect } from "react";
import { useAccount } from "wagmi";
import { useTokenBalances } from "@/hooks/useTokenBalances";
import { TokenList } from "./TokenList";

export function PortfolioDashboard() {
  const { address, isConnected } = useAccount();
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  const {
    data: tokens = [],
    isLoading,
    error,
    refetch,
    isFetching,
  } = useTokenBalances(address, isConnected);

  // Prevent hydration mismatch - don't render until mounted
  if (!mounted) {
    return null;
  }

  // Not connected state
  if (!isConnected) {
    return (
       // UI for wallet not connected state
    );
  }

  return (
    <div className="space-y-8">
        <TokenList
            tokens={tokens}
            isLoading={isLoading}
            error={error?.message}
            showLowValueTokens={showLowValueTokens}
        />
    </div>
  );
}
```

<Tip>
  查看其他 [Moralis API 端点](https://docs.moralis.com/web3-data-api/evm/api-reference)，并为它们创建可视化组件！
</Tip>

## 结论

在本指南中，您学习了如何使用 Moralis API 获取钱包代币余额并在 UI 中展示它们。

强烈建议您查看其他 API 端点，并为您创建的这个项目添加更多功能！

您可以在[这里](https://github.com/monad-developers/portfolio-dashboard-example)找到完整的投资组合仪表板项目以作参考。

## 有用的链接

* [在线投资组合应用](https://portfolio-dashboard-example.vercel.app)
* [Moralis API 参考](https://docs.moralis.com/web3-data-api/evm/api-reference)
* [投资组合仪表板项目](https://github.com/monad-developers/portfolio-dashboard-example)
* [Reown AppKit 文档](https://docs.reown.com/appkit/next/core/installation)
