周末动手:零后端构建多链加密货币投资组合追踪器
厌倦了在五个不同的钱包和区块浏览器之间来回切换?这篇教程教你如何只用前端工具和免费API构建自己的统一加密货币仪表板。使用Next.js、免费区块链API和Tailwind CSS的实战教程。
我在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'
余额获取器是一个查询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;
注意使用Promise.allSettled而不是Promise.all。如果某条链的API调用失败,我们仍然可以从其他链获取结果。这比你想象的更重要。区块链API不稳定。单独的链端点会随机宕机。你的应用应该优雅地处理部分失败。
next: { revalidate: 60 }选项告诉Next.js缓存响应60秒。这可以防止用户反复刷新时对API的频繁请求。
仪表板组件是所有内容汇聚的地方。它接收聚合的余额数据,并以真正有用的方式呈现。
// components/portfolio-dashboard.tsx
组件使用React Query的staleTime和refetchInterval来平衡新鲜度和API使用量。数据在60秒内被认为是新鲜的,并在后台每5分钟自动重新获取。
部署是最简单的部分。将代码推送到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
投资组合历史随时间追踪价值。在localStorage或IndexedDB中存储快照,然后用Recharts等库渲染图表。
价格警报在仓位越过阈值时通知你。这需要某种持久化和通知机制。Web Push API用于浏览器通知,或者你可以集成Telegram或Discord webhooks。
DeFi仓位追踪比较棘手。像Aave、Compound和Uniswap这样的协议有自定义ABI。你需要直接查询它们的合约才能获得准确的仓位数据。Moralis和DeBank API提供这个,但免费层有限制。
投资组合追踪器告诉你拥有什么。但在加密货币中,你还没拥有的东西往往更重要。正在上涨的币,积累异常交易量的代币,价格变动前发生的聪明钱流动。
这就是专用扫描工具发挥作用的地方。EKX.AI的Pre-Pump Scanner同时监控多条链的链上活动模式。它检测异常钱包行为、流动性变化和历史上在重大价格变动之前出现的积累模式。
扫描器不会告诉你该买什么。但它会浮现你在区块浏览器上手动滚动永远不会发现的信号。早期信号随时间复利。周一发现的模式可能会影响你周三建立的仓位。
构建自己的投资组合追踪器是理解区块链数据的第一步。下一步是知道要寻找什么。自动信号检测工具通过浮现你可能错过的机会来补充手动追踪。
速率限制比你预期的更严重。CoinGecko的免费层每分钟大约允许30个请求。如果你的仪表板在加载时查询50个代币的价格,你会被限流。解决办法是激进的缓存和尽可能批量请求。
陈旧价格让用户困惑。Covalent的价格数据可能延迟数小时。为了获得准确的价值,从CoinGecko单独获取价格并按合约地址匹配。这增加了复杂性但给出更好的结果。
缺失代币发生在新的或冷门的代币不在Covalent数据库中时。它们会显示余额但没有价格数据。考虑在API返回不完整数据时回退到合约调用获取代币元数据。
链重组可能导致临时不一致。交易显示,然后消失,然后再次出现。在应用层面你对此无能为力。只需意识到这会发生,当余额闪烁时不要恐慌。
最有价值的教训不是技术性的。而是理解区块链数据实际上是如何流经基础设施栈的。
当你向Covalent查询余额时,它们并不是在实时查询区块链。它们运行索引器处理区块并将数据存储在传统数据库中。你的API调用命中它们的数据库。这就是为什么数据可能延迟,以及为什么不同的提供商对同一地址显示不同的值。
理解这一点改变了你对构建加密应用的思考方式。你不是在从单一真相来源读取。你是在从某人对区块链状态的解释中读取。多个数据源有助于三角定位准确性。
这个周末项目变成了我每天都在使用的东西。更重要的是,它给了我区块链数据的直觉,这是我用任何其他方式都无法获得的。
如果你构建东西,你就理解东西。如果你只是使用别人构建的工具,你总是依赖于他们的选择和限制。这个追踪器教会了我关于多链加密基础设施的东西,比几个月阅读文档学到的还多。
构建你自己的追踪器。发现你自己的边缘情况。那才是真正学习发生的地方。
邮件列表
加入我们的社区
订阅邮件列表,及时获取最新消息和更新
,
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;
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);
}
'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>
);
}
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;
}
周末动手:零后端构建多链加密货币投资组合追踪器 | EKX - 加密货币预涨扫描器