构建多链资产追踪器:Node.js与React实现链上资产聚合与估值
1. 项目概述一个链上资产追踪器的诞生最近在整理自己的数字资产时发现了一个挺普遍但有点烦人的问题当你在不同的区块链网络比如以太坊、BSC、Polygon上持有多种代币Token和NFT时很难有一个统一的视角去实时掌握它们的总价值、交易动态和分布情况。要么得一个个打开不同的区块浏览器要么依赖的中心化交易所或钱包App提供的资产总览功能又不够精细特别是对于DeFi协议里的质押资产、流动性池份额追踪起来更是麻烦。于是我就动手写了一个叫TokenTracker的工具。它的核心目标很简单聚合你在多个区块链地址上的所有链上资产提供一个清晰、实时、可定制的资产仪表盘。你可以把它理解为一个自托管的、功能更强的“区块浏览器资产页面”聚合器。它不管理你的私钥只通过公开的区块链API读取数据所以安全性上你完全不用担心。这个项目特别适合那些积极参与多链DeFi、频繁进行链上交易或者单纯想更清晰管理自己加密资产的朋友。2. 核心需求与设计思路拆解在动手编码之前我花了些时间梳理了核心需求和对应的技术选型。一个资产追踪器听起来简单但拆开来看涉及到的环节不少。2.1 核心需求解析多链支持这是基石。不能只支持一条链。初期我瞄准了以太坊主网、BNB Smart Chain (BSC) 和 Polygon因为它们生态丰富、用户量大。设计上必须考虑良好的扩展性未来能方便地添加Avalanche、Arbitrum等新链。资产类型全覆盖不仅要能识别和查询标准的ERC-20代币余额还要能处理ERC-721和ERC-1155标准的NFT。对于NFT除了数量最好还能拉取元数据如图片、名称、属性并展示。实时定价与估值知道有多少个代币没用关键是要知道它值多少钱。这就需要接入可靠的价格预言机将代币余额乘以实时单价计算出法币如USD总价值。交易历史查询资产变动需要追溯。需要能查询指定地址的历史交易记录、代币转账记录并按时间、类型等进行筛选。友好的用户界面需要一个清晰直观的Web界面来展示所有信息。仪表盘应该包含总资产概览、各链资产分布、代币列表含价格、涨跌幅、NFT画廊以及交易历史列表。数据缓存与性能频繁地直接调用区块链RPC节点或第三方API可能会导致速率限制且速度慢。需要引入缓存层来存储不常变动的数据如代币信息、NFT元数据并优化频繁查询如余额、价格的更新策略。2.2 技术栈选型与考量基于以上需求我选择了以下技术栈并说说为什么这么选后端框架Node.js Express。JavaScript生态在区块链领域有强大的工具库支持如ethers.js、web3.js且开发效率高适合快速构建API服务。Express轻量且灵活。区块链交互库ethers.js v6。相比web3.jsethers.js的API设计更现代、清晰文档也更好。v6版本在Tree Shaking和模块化方面做得更出色有助于减小构建体积。数据获取与缓存区块链数据使用Infura以太坊、BSC Public RPC和Polygon Public RPC作为主要数据源。对于生产环境建议使用付费套餐以获得更高的速率限制和可靠性。价格数据使用CoinGecko API。它免费层额度充足支持的代币和链非常全面是很多项目的首选。缓存使用Redis。将代币信息、NFT元数据、以及短时间内的余额和价格结果缓存起来设置合理的TTL能极大减轻对上游服务的压力提升响应速度。前端框架React Vite TypeScript。React组件化非常适合构建复杂的仪表盘界面。Vite作为构建工具开发体验极佳。TypeScript能显著提升代码的可维护性和开发体验尤其是在处理复杂的区块链数据时。UI库Ant Design (antd)。它提供了丰富、高质量的企业级React组件能快速搭建出专业美观的界面让我更专注于业务逻辑而非样式细节。数据库可选PostgreSQL。主要用于存储用户自定义的观察地址列表、偏好设置等。如果只是个人使用这部分可以简化甚至用配置文件代替。但考虑到未来可能的多用户支持提前设计是值得的。注意技术选型没有绝对的对错关键是匹配项目阶段和团队熟悉度。例如如果你更熟悉Python用web3.py和FastAPI来构建后端也完全可行。3. 核心模块实现细节整个项目我分成了几个核心模块来逐步实现。3.1 多链RPC客户端管理这是与区块链对话的起点。我们不能把不同链的RPC URL硬编码在业务逻辑里。我创建了一个BlockchainService类它根据链IDchainId来管理和提供对应的ethers.js Provider实例。// services/blockchainService.js import { ethers } from ethers; const CHAIN_CONFIGS { 1: { // 以太坊主网 name: Ethereum, rpcUrl: process.env.ETH_RPC_URL || https://mainnet.infura.io/v3/YOUR_KEY, explorer: https://etherscan.io, nativeCurrency: { symbol: ETH, decimals: 18 } }, 56: { // BSC主网 name: BNB Smart Chain, rpcUrl: process.env.BSC_RPC_URL || https://bsc-dataseed.binance.org/, explorer: https://bscscan.com, nativeCurrency: { symbol: BNB, decimals: 18 } }, 137: { // Polygon主网 name: Polygon, rpcUrl: process.env.POLYGON_RPC_URL || https://polygon-rpc.com, explorer: https://polygonscan.com, nativeCurrency: { symbol: MATIC, decimals: 18 } } }; class BlockchainService { constructor() { this.providers {}; this.initProviders(); } initProviders() { for (const [chainId, config] of Object.entries(CHAIN_CONFIGS)) { // 使用JsonRpcProvider连接 this.providers[chainId] new ethers.JsonRpcProvider(config.rpcUrl); // 可以在这里绑定一些链的元信息 this.providers[chainId]._chainConfig config; } } getProvider(chainId) { const provider this.providers[chainId]; if (!provider) { throw new Error(Unsupported chainId: ${chainId}); } return provider; } getChainConfig(chainId) { return CHAIN_CONFIGS[chainId]; } // 一个便捷方法获取所有支持的链 getAllSupportedChains() { return Object.values(CHAIN_CONFIGS); } } export default new BlockchainService(); // 导出单例这样在业务代码中我只需要import blockchainService然后通过blockchainService.getProvider(56)就能拿到BSC的Provider非常清晰。3.2 资产余额获取代币与NFT这是最核心的功能之一。对于一个给定的地址我们需要获取其原生币如ETH、BNB余额和所有代币余额。1. 原生币余额这个最简单直接用Provider的getBalance方法。async getNativeBalance(address, chainId) { const provider this.getProvider(chainId); const balanceWei await provider.getBalance(address); // 转换为可读的单位如ETH const balance ethers.formatEther(balanceWei); return { balance, symbol: this.getChainConfig(chainId).nativeCurrency.symbol }; }2. ERC-20代币余额这里有个问题我们怎么知道一个地址持有哪些代币区块链上没有直接的“查询地址所有代币”的接口。通常有两种策略预定义代币列表维护一个各链上主流、常见代币的合约地址列表然后批量查询余额。这种方式快但会遗漏用户持有的长尾代币。扫描交易记录通过区块浏览器API如Etherscan获取地址所有的代币转账事件从中提取出出现过的代币合约地址集合。这种方式全面但API有调用限制且速度慢。我采用了混合策略首先查询一个主流代币列表的余额确保覆盖主流资产同时提供一个“扫描”功能当用户手动触发时去调用区块浏览器的API来补充可能遗漏的代币。查询单个代币余额需要用到代币合约的ABI。// 简化的ERC-20 ABI只需要balanceOf和decimals函数 const ERC20_ABI [ function balanceOf(address owner) view returns (uint256), function decimals() view returns (uint8), function symbol() view returns (string) ]; async getTokenBalances(address, chainId, tokenAddresses) { const provider this.getProvider(chainId); const balancePromises tokenAddresses.map(async (tokenAddr) { try { const contract new ethers.Contract(tokenAddr, ERC20_ABI, provider); // 并行查询余额、小数位和符号 const [balanceWei, decimals, symbol] await Promise.all([ contract.balanceOf(address), contract.decimals(), contract.symbol() ]); const balance ethers.formatUnits(balanceWei, decimals); return { contractAddress: tokenAddr, symbol, balance, decimals, balanceWei: balanceWei.toString() }; } catch (error) { console.error(Error fetching token ${tokenAddr} on chain ${chainId}:, error); // 对于出错的代币返回null后续过滤掉 return null; } }); const results await Promise.all(balancePromises); // 过滤掉查询失败合约可能不是ERC20或已废弃和余额为0的项 return results.filter(item item ! null item.balanceWei ! 0); }3. NFT余额获取流程类似但更复杂。需要区分ERC-721和ERC-1155。对于ERC-721一个合约地址下可能有多个tokenId需要查询balanceOf和tokenOfOwnerByIndex来枚举。对于ERC-1155则需要查询balanceOf并可能涉及批量查询。同样获取NFT的元数据图片、名称需要调用tokenURI然后去解析可能是IPFS链接、HTTP链接或Base64编码的数据。这部分代码量较大核心是处理好异步枚举和元数据获取的缓存因为tokenURI调用和HTTP获取可能很慢。我通常会为NFT的元数据设置一个较长的Redis缓存时间比如24小时。实操心得在批量查询代币或NFT余额时一定要做好错误处理try...catch和并行优化Promise.all。有些代币合约可能不标准比如decimals()函数缺失或者RPC节点暂时无响应。不能让一个失败的请求导致整个批量查询挂掉。此外将余额为0的资产过滤掉能让前端展示更清晰。3.3 价格获取与资产估值获取到余额后下一步就是估值。这里我选择了CoinGecko API。1. 代币价格映射CoinGecko API需要每个代币的id如ethereum来查询价格。但我们的资产列表里只有合约地址和符号。因此我们需要一个从“链合约地址”到CoinGecko代币ID的映射表。CoinGecko提供了/api/v3/coins/list?include_platformtrue接口可以获取所有代币及其在各链上的合约地址信息。我写了一个脚本定期比如每天运行将这个列表拉取下来并存储到Redis或数据库中构建一个快速的查找字典。2. 批量查询价格CoinGecko的/api/v3/simple/price接口支持通过代币ID批量查询价格。我们需要将资产列表中的代币转换成对应的ID然后一次性查询。// services/priceService.js import axios from axios; import redisClient from ../cache/redisClient.js; // 假设的Redis客户端 class PriceService { constructor() { this.coingeckoBaseUrl https://api.coingecko.com/api/v3; } async getTokenPrices(tokenGeckoIds) { const cacheKey prices:${tokenGeckoIds.sort().join(,)}; // 尝试从缓存读取缓存5分钟 const cached await redisClient.get(cacheKey); if (cached) { return JSON.parse(cached); } try { const response await axios.get(${this.coingeckoBaseUrl}/simple/price, { params: { ids: tokenGeckoIds.join(,), vs_currencies: usd, include_market_cap: false, include_24hr_vol: false, include_24hr_change: true, // 包含24小时涨跌幅 include_last_updated_at: true } }); const prices response.data; // 存入缓存TTL设为300秒5分钟 await redisClient.setex(cacheKey, 300, JSON.stringify(prices)); return prices; } catch (error) { console.error(Error fetching prices from CoinGecko:, error); // 如果API失败可以尝试返回缓存中可能存在的旧数据或者抛出错误 throw new Error(Price service unavailable); } } // 根据合约地址查找CoinGecko ID需要预加载的映射表 async findGeckoId(chainId, contractAddress) { const mapKey coingecko_map:${chainId}:${contractAddress.toLowerCase()}; return await redisClient.get(mapKey); } }3. 计算总价值有了余额和单价计算就简单了总价值 余额 * 单价。需要特别注意小数位的处理避免JavaScript浮点数精度问题。我通常会使用BigNumber库ethers.js自带来进行精确的数学运算。import { ethers } from ethers; function calculateValue(balance, price, decimals) { // balance是格式化后的字符串如“10.5” // price是数字如2500.50 // 先将balance字符串根据其decimals转换回最小单位wei的BigNumber // 但更简单的是直接用decimal.js或直接计算 // 这里简单处理注意精度损失风险 const numericBalance parseFloat(balance); if (isNaN(numericBalance) || !price) return 0; return numericBalance * price; }对于生产环境建议使用decimal.js或ethers.formatUnits配合BigNumber进行更精确的计算。4. 后端API设计与数据聚合后端的主要职责是提供一个清晰的API接收前端传来的地址列表然后并行查询各链资产聚合数据后返回。4.1 核心聚合API端点我设计了一个主要的POST /api/portfolio端点。请求体:{ addresses: [ {chainId: 1, address: 0x...}, {chainId: 56, address: 0x...} ] }后端处理流程:参数校验检查地址格式和链ID是否支持。并行查询对每个(chainId, address)对并行执行调用BlockchainService获取原生币余额。调用预定义的代币列表和可能的扫描逻辑获取ERC-20代币列表及余额。调用NFT查询逻辑获取NFT列表及元数据。将所有代币包括原生币的标识符合约地址或coingecko_id收集起来。批量获取价格将上一步收集的所有代币标识符去重后调用PriceService批量获取实时价格。数据聚合与计算将价格映射回每个代币资产。计算每个代币的美元价值。按链、按资产类型分类汇总。计算总资产价值。返回响应将结构化的数据返回给前端。// 响应结构示意 { totalValueUSD: 125430.50, chains: [ { chainId: 1, chainName: Ethereum, valueUSD: 85430.20, nativeAsset: { symbol: ETH, balance: 3.2, valueUSD: 9600.00, ... }, tokens: [ ... ], // ERC20代币列表 nfts: [ ... ] // NFT列表 }, { chainId: 56, chainName: BSC, valueUSD: 40000.30, nativeAsset: { ... }, tokens: [ ... ], nfts: [] } ], updatedAt: 2023-10-27T10:30:00Z }4.2 性能优化策略这个聚合查询涉及大量外部API调用如果不加优化响应时间会很长。Redis缓存层层递进代币元数据符号、小数位缓存时间较长24小时因为很少变化。NFT元数据JSON、图片链接缓存24小时。价格数据缓存5分钟。对于资产总览5分钟内的价格波动通常可以接受。余额数据谨慎缓存。因为余额变化频繁。可以考虑为余额设置一个非常短的缓存比如15-30秒或者不缓存但对于不活跃地址可以适当缓存几分钟。聚合结果可以为特定的地址列表组合生成一个缓存键设置短时间缓存如30秒适合用户频繁刷新。并行与异步大量使用Promise.all来并行执行独立的网络请求如同时查询多个链、查询多个代币的余额。超时与重试为每个外部API调用RPC、CoinGecko设置合理的超时时间并实现简单的重试逻辑特别是对于偶发的RPC节点无响应。分页与懒加载对于NFT数量特别多的地址在API响应中可以先只返回总数和前几个前端通过额外的请求来分页加载详情。交易历史记录也必须分页查询。5. 前端仪表盘构建前端的目标是将后端聚合的数据清晰、直观地呈现出来。5.1 技术实现要点状态管理使用React Context或Zustand这样的轻量级状态库来管理全局状态如当前查询的地址列表、资产数据、加载状态等。数据获取使用axios或fetch调用后端API结合React Query或SWR库来管理服务端状态、缓存、轮询和错误处理。这能极大简化数据同步的逻辑。UI布局顶部概览用大的数字卡片展示总资产价值、24小时变化、各链价值分布可以用饼图或环形图使用recharts或victory库。资产列表用表格展示所有代币列包括资产图标从CoinGecko或缓存获取、名称/符号、持仓量、单价、持仓价值、24小时涨跌幅、操作如跳转到区块浏览器。NFT画廊用网格Masonry Grid布局展示NFT每张卡片显示图片、名称、编号。图片加载需要处理错误情况显示占位图。交易历史可折叠或独立页签表格展示交易哈希、时间、类型发送/接收、对方地址、金额、状态。5.2 关键交互与体验优化地址管理提供输入框让用户添加新的链上地址并本地存储localStorage或同步到后端数据库如果登录了。支持给地址打标签如“我的主钱包”、“DeFi专用”。自动刷新提供手动刷新按钮也可以设置自动刷新间隔如每60秒。自动刷新时只重新获取余额和价格不重新获取元数据等静态信息。资产筛选与排序用户可以根据链、资产类型代币/NFT、价值大小等进行筛选和排序。错误边界与加载状态每个数据区域都要有良好的加载中Skeleton Screen和错误提示状态。网络请求失败后提供重试按钮。响应式设计确保在手机和桌面端都有良好的浏览体验。6. 部署与运维考量开发完成后要让它能稳定运行。环境变量所有敏感信息Infura项目ID、CoinGecko API Key、Redis连接字符串、数据库密码必须通过环境变量配置绝不能硬编码在代码中。进程管理使用pm2或Docker来管理Node.js进程确保服务崩溃后能自动重启。日志记录使用winston或pino等日志库记录详细的请求日志、错误日志和性能日志方便排查问题。监控与告警监控服务器的CPU、内存、磁盘。监控API的响应时间和错误率。可以设置简单的健康检查端点/health。安全使用helmet中间件设置安全的HTTP头。对用户输入的地址进行严格格式校验防止注入攻击。如果开放给多用户使用需要考虑API速率限制、身份认证和授权。容器化可选但推荐使用Docker将应用、Redis、数据库等容器化用docker-compose编排能极大简化部署和环境一致性问题。7. 遇到的典型问题与解决方案在开发过程中踩了不少坑这里记录几个典型的问题一RPC节点速率限制与稳定性现象批量查询代币余额时偶尔会出现超时或“rate limit”错误导致整个查询失败或部分数据缺失。解决使用付费节点对于主网Infura、Alchemy的付费套餐是必须的它们提供更高的调用频率和稳定性。实现请求队列与限流自己实现一个简单的队列控制对同一个RPC节点的并发请求数并在请求失败时加入指数退避重试。多节点故障转移配置多个RPC节点URL当主节点失败时自动切换到备用节点。更激进的缓存对于不活跃的观察地址其持有的代币列表变化不频繁可以将“地址持有哪些代币合约”这个列表也缓存起来比如1小时减少不必要的balanceOf调用。问题二CoinGecko API的调用频率限制现象免费版每分钟30次调用如果用户观察地址多、资产种类多很容易超限。解决批量查询是王道一定要用/simple/price接口的批量查询功能将几十个代币ID一次查询。价格缓存这是最关键的措施。将价格数据缓存至少5分钟。大多数用户刷新仪表盘5分钟内的价格变化是可以接受的。考虑备用数据源可以集成多个价格预言机如1inch的Token Price API、Chainlink的喂价对于主流币作为CoinGecko的备份或补充。问题三NFT元数据获取慢且格式不一现象tokenURI返回的可能是IPFS网关链接、HTTP链接或直接内嵌的Base64数据。IPFS网关可能很慢有些HTTP链接甚至已经失效。元数据JSON格式也并非完全标准。解决异步获取与缓存在前端展示时不要阻塞主要资产数据的渲染。可以先显示NFT的占位符和tokenId在后台异步加载图片。服务端将获取到的元数据尤其是图片URL持久化缓存。IPFS网关优选如果检测到是IPFS链接ipfs://可以将其转换为多个公共网关如Cloudflare的、官方的进行尝试选择最快的。超时与降级设置合理的获取超时如3秒如果超时或失败则显示一个通用的NFT图标并记录错误日志。问题四前端渲染大量NFT图片导致卡顿现象一个地址可能有成千上万个NFT一次性渲染所有图片会导致浏览器内存和CPU占用极高页面卡死。解决虚拟滚动使用react-window或react-virtualized库只渲染可视区域内的NFT项。分页加载后端API对NFT列表进行分页前端每次只加载一页比如50个。图片懒加载使用loading”lazy”属性或Intersection Observer API让图片只在进入视口时才加载。8. 项目总结与扩展思考构建TokenTracker的过程是一个典型的全栈项目实践涉及了区块链交互、第三方API集成、前后端分离、性能优化等多个方面。它从一个具体的需求痛点出发通过合理的架构设计和一步步的编码实现最终形成了一个能解决实际问题的工具。这个项目还有很大的扩展空间支持更多链按照现有的模式添加新的链如Arbitrum, Optimism, Avalanche主要是配置RPC和区块浏览器信息。DeFi头寸集成更进阶的功能是解析用户在Uniswap、Aave、Compound等DeFi协议中的头寸例如流动性池份额、抵押借贷的头寸等。这需要与协议的智能合约进行更复杂的交互解析特定的事件日志。价格预警可以为持有的特定代币设置价格预警当价格达到某个阈值时发送通知邮件、Telegram等。多账户与分享支持用户管理多个地址组合并生成一个只读的资产概览链接分享给他人。本地化部署提供Docker一键部署脚本让用户可以在自己的服务器或甚至本地机器上运行彻底掌控数据隐私。我个人最大的体会是在区块链开发中错误处理、缓存策略和性能优化的重要性丝毫不亚于业务逻辑本身。网络的不确定性、外部服务的限流、数据的海量这些都是在设计之初就必须充分考虑的问题。从0到1做出一个能稳定运行的工具带来的成就感远超单纯调用一个现成的API。如果你也对链上数据感兴趣不妨从这样一个项目开始动手过程中遇到的每一个问题都会让你对Web3的技术栈有更深的理解。

相关新闻

最新新闻

日新闻

周新闻

月新闻