周末动手:零后端构建多链加密货币投资组合追踪器
用Next.js和免费API构建自己的统一加密货币仪表板,无需后端服务器。完整实战教程含代码示例。
我在Ethereum上有代币。Arbitrum上也有一些。Polygon上还有几个NFT。每天早上我要打开四个不同的区块浏览器才能看清我到底持有什么。这很繁琐,容易出错,而且完全没必要。
上周末我构建了一个统一的投资组合追踪器,它可以从多条链拉取余额,计算总USD价值,并在一个干净的仪表板上显示所有内容。没有后端服务器。没有数据库。只是一个调用免费API的Next.js应用,部署到Vercel零成本。
整个项目在周六和周日花了大约12小时。如果你会写基础的React,你也可以做到。
这篇教程将完整介绍我是如何构建它的。不是那种一切都完美运行的美化版本。而是真实版本,包括我发现的API怪癖、速率限制的坑,以及真正重要的架构决策。
为什么要自己构建而不是使用现有工具
市面上已经有几十个投资组合追踪器了。Zerion、Zapper、DeBank。它们都很好。我也用。但它们有一些局限性促使我自己构建。
首先是隐私。每次你将钱包连接到第三方追踪器,你就是在信任他们掌握你完整的财务档案。他们确切知道你持有什么,什么时候买的,以及价值多少。其中一些服务会将这些数据变现。
其次是定制化。我想要一些不存在的特定功能。当某个仓位跌破阈值时的实时警报。按策略自定义资产分组。与我自己的交易信号集成。没有现有工具能完全满足我的需求。
第三是学习。自己构建东西比任何教程都能让你学到更多。你会发现边缘情况,理解API限制,并培养出单纯阅读文档无法获得的区块链数据直觉。
我构建的追踪器完全在浏览器中运行。你的钱包地址永远不会触及我的服务器,因为根本没有服务器。所有事情都在客户端发生,API密钥存储在环境变量中,由Vercel在构建时注入。
真正有效的技术栈
尝试了几种组合之后,以下是效果最好的。
Next.js 14 配合App Router。服务器组件让你可以在不向客户端暴露API密钥的情况下获取数据。内置缓存优雅地处理速率限制。而Vercel部署只需一键。
Tailwind CSS 用于样式。周末项目没时间浪费在CSS架构上。Tailwind让你快速移动,结果看起来也很专业。
Covalent API 用于多链余额查询。这是关键发现。Covalent提供了一个统一的API,可以在100多条链上工作。一个API调用返回特定链上某个地址的所有代币余额。免费层每月给你100,000积分,对个人使用来说足够了。
CoinGecko API 用于价格数据。免费层,基础端点不需要API密钥。根据端点不同,每分钟限制10-30次调用,但对于每几分钟刷新一次的投资组合追踪器来说足够了。
React Query 用于数据获取和缓存。自动处理加载状态、错误状态和后台重新获取。又少了一件需要从头构建的事情。
项目设置:第一小时
从Create Next App开始。使用App Router和TypeScript。如果你喜欢更扁平的结构,可以跳过src目录。
npx create-next-app@latest crypto-tracker --typescript --tailwind --app
cd crypto-tracker安装你需要的依赖。
npm install @tanstack/react-query axios为API密钥创建环境文件。你需要一个Covalent API密钥,可以在covalenthq.com免费获取。
COVALENT_API_KEY=your_key_here
NEXT_PUBLIC_SUPPORTED_CHAINS=1,137,42161,56,43114链ID分别代表Ethereum (1)、Polygon (137)、Arbitrum (42161)、BSC (56)和Avalanche (43114)。根据你使用的链来增减。
构建核心数据层
架构有三层。定义支持网络的链配置。查询Covalent的余额获取器。以及用CoinGecko的USD价值来丰富余额的价格聚合器。
这是链配置。这个文件定义了应用需要了解的关于每条支持链的所有信息。
// lib/chains.ts
export const CHAINS = {
1: {
name: 'Ethereum',
symbol: 'ETH',
color: '#627EEA',
covalentChainId: 'eth-mainnet',
coingeckoId: 'ethereum',
explorer: 'https://etherscan.io'
},
137: {
name: 'Polygon',
symbol: 'MATIC',
color: '#8247E5',
covalentChainId: 'matic-mainnet',
coingeckoId: 'polygon-pos',
explorer: 'https://polygonscan.com'
},
42161: {
name: 'Arbitrum',
symbol: 'ETH',
color: '#12AAFF',
covalentChainId: 'arbitrum-mainnet',
coingeckoId: 'arbitrum-one',
explorer: 'https://arbiscan.io'
},
56: {
name: 'BSC',
symbol: 'BNB',
color: '#F0B90B',
covalentChainId: 'bsc-mainnet',
coingeckoId: 'binance-smart-chain',
explorer: 'https://bscscan.com'
},
43114: {
name: 'Avalanche',
symbol: 'AVAX',
color: '#E84142',
covalentChainId: 'avalanche-mainnet',
coingeckoId: 'avalanche',
explorer: 'https://snowtrace.io'
}
} as const;
export type ChainId = keyof typeof CHAINS;余额获取器是一个查询Covalent的服务器操作。服务器操作在这里很完美,因为它们在服务器上运行,保持你的API密钥隐藏。
// app/actions/balances.ts
'use server'
import { CHAINS, ChainId } from '@/lib/chains';
interface TokenBalance {
contract_address: string;
contract_name: string;
contract_ticker_symbol: string;
contract_decimals: number;
balance: string;
quote: number;
logo_url: string;
}
interface ChainBalances {
chainId: ChainId;
chainName: string;
tokens: TokenBalance[];
totalUsd: number;
}
export async function getBalancesForChain(
address: string,
chainId: ChainId
): Promise<ChainBalances> {
const chain = CHAINS[chainId];
const apiKey = process.env.COVALENT_API_KEY;
const response = await fetch(
`https://api.covalenthq.com/v1/${chain.covalentChainId}/address/${address}/balances_v2/?key=${apiKey}`,
{ next: { revalidate: 60 } }
);
if (!response.ok) {
throw new Error(`Failed to fetch balances for ${chain.name}`);
}
const data = await response.json();
const items = data.data?.items || [];
const tokens = items
.filter((item: any) => parseFloat(item.balance) > 0)
.map((item: any) => ({
contract_address: item.contract_address,
contract_name: item.contract_name,
contract_ticker_symbol: item.contract_ticker_symbol,
contract_decimals: item.contract_decimals,
balance: item.balance,
quote: item.quote || 0,
logo_url: item.logo_url
}));
const totalUsd = tokens.reduce((sum: number, t: TokenBalance) => sum + t.quote, 0);
return {
chainId,
chainName: chain.name,
tokens,
totalUsd
};
}
export async function getAllBalances(address: string): Promise<ChainBalances[]> {
const chainIds = Object.keys(CHAINS).map(Number) as ChainId[];
const results = await Promise.allSettled(
chainIds.map(chainId => getBalancesForChain(address, chainId))
);
return results
.filter((r): r is PromiseFulfilledResult<ChainBalances> => r.status === 'fulfilled')
.map(r => r.value);
}注意使用Promise.allSettled而不是Promise.all。如果某条链的API调用失败,我们仍然可以从其他链获取结果。这比你想象的更重要。区块链API不稳定。单独的链端点会随机宕机。你的应用应该优雅地处理部分失败。
next: { revalidate: 60 }选项告诉Next.js缓存响应60秒。这可以防止用户反复刷新时对API的频繁请求。
构建仪表板组件
仪表板组件是所有内容汇聚的地方。它接收聚合的余额数据,并以真正有用的方式呈现。
// components/portfolio-dashboard.tsx
'use client'
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getAllBalances } from '@/app/actions/balances';
import { CHAINS, ChainId } from '@/lib/chains';
function formatUsd(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value);
}
function formatTokenBalance(balance: string, decimals: number): string {
const value = parseFloat(balance) / Math.pow(10, decimals);
if (value < 0.0001) return '<0.0001';
if (value < 1) return value.toFixed(4);
if (value < 1000) return value.toFixed(2);
return value.toLocaleString('en-US', { maximumFractionDigits: 2 });
}
export function PortfolioDashboard({ address }: { address: string }) {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['balances', address],
queryFn: () => getAllBalances(address),
staleTime: 60 * 1000,
refetchInterval: 5 * 60 * 1000
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500" />
</div>
);
}
if (error) {
return (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4">
<p className="text-red-400">加载投资组合数据失败</p>
<button
onClick={() => refetch()}
className="mt-2 text-sm text-red-300 underline"
>
重试
</button>
</div>
);
}
const totalPortfolioValue = data?.reduce((sum, chain) => sum + chain.totalUsd, 0) || 0;
const sortedChains = [...(data || [])].sort((a, b) => b.totalUsd - a.totalUsd);
return (
<div className="space-y-6">
<div className="bg-gradient-to-r from-purple-500/20 to-cyan-500/20 rounded-xl p-6">
<p className="text-gray-400 text-sm">总投资组合价值</p>
<p className="text-4xl font-bold text-white mt-1">
{formatUsd(totalPortfolioValue)}
</p>
<p className="text-gray-500 text-sm mt-2">
跨{sortedChains.length}条链 • 刚刚更新
</p>
</div>
<div className="grid gap-4">
{sortedChains.map(chain => (
<ChainCard key={chain.chainId} chain={chain} />
))}
</div>
</div>
);
}组件使用React Query的staleTime和refetchInterval来平衡新鲜度和API使用量。数据在60秒内被认为是新鲜的,并在后台每5分钟自动重新获取。
部署到Vercel:最后一步
部署是最简单的部分。将代码推送到GitHub,然后将仓库连接到Vercel。
唯一需要的配置是在Vercel仪表板中设置环境变量。添加COVALENT_API_KEY和你的密钥。Vercel在构建时注入这些,所以它们永远不会暴露给客户端。
git init
git add .
git commit -m "Initial commit"
git remote add origin https://github.com/yourusername/crypto-tracker.git
git push -u origin main在Vercel中,点击"New Project",从GitHub导入,然后部署。就这样。你的投资组合追踪器上线了。
值得添加的高级功能
基本的追踪器可以工作了,但还有改进空间。以下是我在接下来几周添加的功能。
ENS解析让用户输入名称而不是地址。viem库优雅地处理这个问题。
import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';
const client = createPublicClient({
chain: mainnet,
transport: http()
});
async function resolveAddress(input: string): Promise<string> {
if (input.endsWith('.eth')) {
const resolved = await client.getEnsAddress({ name: input });
if (!resolved) throw new Error('未找到ENS名称');
return resolved;
}
return input;
}投资组合历史随时间追踪价值。在localStorage或IndexedDB中存储快照,然后用Recharts等库渲染图表。
价格警报在仓位越过阈值时通知你。这需要某种持久化和通知机制。Web Push API用于浏览器通知,或者你可以集成Telegram或Discord webhooks。
DeFi仓位追踪比较棘手。像Aave、Compound和Uniswap这样的协议有自定义ABI。你需要直接查询它们的合约才能获得准确的仓位数据。Moralis和DeBank API提供这个,但免费层有限制。
超越基础追踪:发现早期机会
投资组合追踪器告诉你拥有什么。但在加密货币中,你还没拥有的东西往往更重要。正在上涨的币,积累异常交易量的代币,价格变动前发生的聪明钱流动。
这就是专用扫描工具发挥作用的地方。EKX.AI的Trending Scanner同时监控多条链的链上活动模式。它检测异常钱包行为、流动性变化和历史上在重大价格变动之前出现的积累模式。
扫描器不会告诉你该买什么。但它会浮现你在区块浏览器上手动滚动永远不会发现的信号。早期信号随时间复利。周一发现的模式可能会影响你周三建立的仓位。
构建自己的投资组合追踪器是理解区块链数据的第一步。下一步是知道要寻找什么。自动信号检测工具通过浮现你可能错过的机会来补充手动追踪。
常见陷阱及解决方法
在构建这个并与他人分享之后,某些问题反复出现。
速率限制比你预期的更严重。CoinGecko的免费层每分钟大约允许30个请求。如果你的仪表板在加载时查询50个代币的价格,你会被限流。解决办法是激进的缓存和尽可能批量请求。
陈旧价格让用户困惑。Covalent的价格数据可能延迟数小时。为了获得准确的价值,从CoinGecko单独获取价格并按合约地址匹配。这增加了复杂性但给出更好的结果。
缺失代币发生在新的或冷门的代币不在Covalent数据库中时。它们会显示余额但没有价格数据。考虑在API返回不完整数据时回退到合约调用获取代币元数据。
链重组可能导致临时不一致。交易显示,然后消失,然后再次出现。在应用层面你对此无能为力。只需意识到这会发生,当余额闪烁时不要恐慌。
构建这个我学到了什么
最有价值的教训不是技术性的。而是理解区块链数据实际上是如何流经基础设施栈的。
当你向Covalent查询余额时,它们并不是在实时查询区块链。它们运行索引器处理区块并将数据存储在传统数据库中。你的API调用命中它们的数据库。这就是为什么数据可能延迟,以及为什么不同的提供商对同一地址显示不同的值。
理解这一点改变了你对构建加密应用的思考方式。你不是在从单一真相来源读取。你是在从某人对区块链状态的解释中读取。多个数据源有助于三角定位准确性。
这个周末项目变成了我每天都在使用的东西。更重要的是,它给了我区块链数据的直觉,这是我用任何其他方式都无法获得的。
如果你构建东西,你就理解东西。如果你只是使用别人构建的工具,你总是依赖于他们的选择和限制。这个追踪器教会了我关于多链加密基础设施的东西,比几个月阅读文档学到的还多。
构建你自己的追踪器。发现你自己的边缘情况。那才是真正学习发生的地方。

Ready to test signals with real data?
Start scanning trend-oversold signals now
See live market signals, validate ideas, and track performance with EKX.AI.
更多文章
可验证推理:AI 与 Web3 信任之间的缺失环节
深入解析零知识证明、TEE和乐观验证如何让AI输出在区块链上实现密码学可信。完整技术指南。
InfoFi 深度剖析:为什么代币化注意力是加密货币最具争议的实验
InfoFi 将信息和注意力转化为可交易资产。但 ZachXBT 称其为'本轮周期中最大骗局'。这里是完整分析。
Price Impact Curves: Quantifying Asset Velocity and Liquidity
Master price impact curves for crypto trading. Learn AMM math, order book execution strategies, and techniques to minimize costs for any trade size.
邮件列表
加入我们的社区
订阅邮件列表,及时获取最新消息和更新