Polkadot.js链上状态查询实战:账户余额与资产数据获取指南

发布时间:2026/9/16 6:31:50
Polkadot.js链上状态查询实战:账户余额与资产数据获取指南 做波卡生态开发的朋友不管你是写资产管理工具、做数据分析平台还是给用户做余额展示页面基本都会碰到同一个需求把链上的账户余额和资产信息准确、高效地查出来。其实只要用过Polkadot.js这套操作就非常简单了——它几乎是波卡生态所有前端工具、钱包、浏览器查询服务的底层基础设施。这篇文章我直接把实战中用得最多的链上状态查询完整拆开讲从 Polkadot.js 的底层逻辑到连接节点的环境准备再到账户余额、Asset 资产、平行链 Token 的查询代码最后附上我踩过的坑和排查方法。全程使用真实的 Polkadot 主网节点代码可以直接复制跑通适合正在做 DApp、钱包、链上监控脚本的开发者参考。1. Polkadot.js 能查什么链上状态查询的底层逻辑1.1 为什么选择 Polkadot.js 而不是直接发 RPC 请求很多刚接触波卡生态的同学会问既然 Substrate 链暴露了 JSON-RPC 接口为什么不直接用 WebSocket 调state_getStorage非要封装一层 Polkadot.js原因在于直接裸调 RPC 会非常痛苦。Substrate 的状态存储是类型化的键值对的 Key 是经过 SCALE 编码、Blake2 哈希之后的结果。你在浏览器里看到一个账户地址想查它的余额对应的存储 Key 怎么算手动编码 SCALE、拼前缀、算哈希光这一步就能劝退大多数人。更别说链上升级 Runtime 后存储结构可能变化你需要跟着元数据Metadata一起维护。Polkadot.js 把这些全都封装好了。它每隔一段时间会同步链的 Metadata自动知道每个 Pallet 的 Storage 结构长什么样。你只需要写const accountInfo await api.query.system.account(address);它内部帮你完成 Storage Key 构造、RPC 调用、SCALE 解码、类型映射这一整套流程。这就像你访问数据库时用 ORM 而不用手写 JDBC 连接和二进制协议解析省下大量重复工作。1.2 查询动作的本质从 Runtime Storage 读取状态要说清楚链上状态查询得先理解 Substrate 的 Runtime Storage 模型。波卡上的每条链不管中继链还是平行链运行逻辑都由 Runtime 定义。Runtime 里的 Pallet模块会把状态存储在链上例如 Balances Pallet 存储每个账户的余额System Pallet 存储账户的 nonce 和存活信息Assets Pallet 存储资产账本。这些存储项本质上是键值对。不同的存储项有不同的 Key 生成规则简单值比如“当前区块号”Key 是存储项前缀直接哈希。映射值比如“账户 - 余额”Key 是存储项前缀加账户地址编码后哈希。双映射值比如“资产ID 账户地址 - 持仓信息”Key 是两层前缀和两个参数编码后哈希。Polkadot.js 的api.query.*系列方法就是把这些复杂的 Key 生成、编码、解码逻辑全部屏蔽掉。你按方法名和参数去调用它返回的是经过类型注册表解码后的 JavaScript 对象。理解了这一层后面遇到任何自定义 Pallet只要它实现了#[pallet::storage]你都能用同样的方式查出来只是方法路径不同而已。2. 环境准备与节点接入2.1 项目初始化与依赖安装先准备一个干净的工作目录初始化 npm 项目然后安装 Polkadot.js 的核心包mkdir polkadot-state-query cd polkadot-state-query npm init -y npm install polkadot/api polkadot/util这里有两个包polkadot/api是主库负责与链交互、类型解码、查询封装polkadot/util提供一些工具方法比如格式化余额的formatBalance、十六进制转换等。Node 版本建议 18 以上早于 14 的版本会出现fetch或WebSocket相关的兼容性问题我现在项目里统一用 Node 20 LTS跑得很稳。2.2 选择 WebSocket 节点别在主网上跑公开 RPCPolkadot 主网的节点类型是 WS 或 WSS 协议官方公开节点是wss://rpc.polkadot.io。这是 Web3 Foundation 维护的公共服务做学习和原型验证完全够用。如果做生产环境的应用我建议优先考虑这几个选项节点来源特点适合场景官方公开节点wss://rpc.polkadot.io免费、连接稳定、有速率限制学习、原型、低频查询Sous/OnFinality/Blast 等公共节点有免费层、有时带 API Key 机制中小型应用、并发稍高自建节点成本高、维护麻烦、无速率限制高频查询、数据服务、监控我自己因为之前做过一套链上持仓监控服务高频轮询会触发公共节点限流最后直接跑了一个自建节点。不过这篇文章里的示例用官方节点就行足够演示。2.3 建立连接并验证链信息连接节点很简单一次ApiPromise.create就完成了const { ApiPromise, WsProvider } require(polkadot/api); const WS_URL wss://rpc.polkadot.io; async function connect() { const provider new WsProvider(WS_URL); const api await ApiPromise.create({ provider }); const chain await api.rpc.system.chain(); const nodeName await api.rpc.system.name(); const nodeVersion await api.rpc.system.version(); const properties await api.rpc.system.properties(); console.log(链: ${chain}); console.log(节点: ${nodeName} v${nodeVersion}); console.log(Token 精度: ${properties.tokenDecimals.toString()}); return api; }这里有个小细节api.rpc.system.properties()返回的是链的属性和默认配置其中tokenDecimals会告诉你有几位小数。Polkadot 上的 DOT 是 10 位小数1 DOT 10^10 Planck。这个精度信息后面格式化余额时非常关键。提示WsProvider带自动重连机制链路断开后会尝试重连默认重试间隔是 1000ms可以传第二个参数控制const provider new WsProvider(WS_URL, 2000);3. 账户余额查询基础版与升级版3.1 最简余额查询system.account 一个调用搞定在 Substrate Runtime 里所有账户的基础信息都存在 System Pallet 的Account存储中。查询代码如下const ADDRESS 15oF4uVJwmo4TdGW7VfQxNLavjCXviqxT9S1MgbjMNHr6Sp5; const accountInfo await api.query.system.account(ADDRESS); console.log(accountInfo.toHuman());返回内容大致长这样{ nonce: 18, consumers: 0, providers: 1, sufficients: 0, data: { free: 1234567890000000, reserved: 10000000000, frozen: 0, flags: 0 } }free就是可自由支配的余额reserved是预留余额比如参与 Staking 时被锁定的那部分。frozen是冻结金额也就是当前不能转账的部分。nonce是这个账户发起的交易序号签名交易时要用到。注意toHuman()会把十进制余额带千分位分隔符输出成字符串方便阅读但程序里做加减运算时千万不要用它直接用 BigInt 或 BN 进行操作。3.2 可转账余额的真实含义free、reserved、locked 和 frozen很多新手在查询余额时只取free然后直接拿给用户看结果发现用户说“我明明有 100 DOT为什么转账时只能转 80”这里的关键是要理解不同字段的业务含义。从实际操作角度看账户中的 DOT 大致分三类状态可自由转账的部分free - frozen这是你可以转出去、可以消费的部分。被冻结的部分frozen可能是投票锁、Staking 锁、转账手续费预留等原因。预留部分reserved通常与身份、存款项关联不能参与普通转账。所以“可用余额”应当是free.sub(frozen)而不是直接拿free去展示。如果要更严谨还需要考虑 EDExistential Deposit最小存活余额当余额低于 ED 时链上会直接清理该账户转出时系统会拒绝可能导致余额跌破 ED 的交易。还有一个关键点旧版 Substrate 中data里有miscFrozen和feeFrozen两个字段新版统称为frozen。如果你的代码需要兼容老链建议这样取值const { free, reserved, frozen, miscFrozen, feeFrozen } accountInfo.data; const actualFrozen frozen ? frozen : miscFrozen.gt(feeFrozen) ? miscFrozen : feeFrozen;3.3 组合示例严密计算用户可用余额我把环境准备、连接、查询、格式化串成一个完整脚本可以直接保存成query-balance.js运行const { ApiPromise, WsProvider } require(polkadot/api); const { formatBalance } require(polkadot/util); const WS_URL wss://rpc.polkadot.io; const ADDRESS 15oF4uVJwmo4TdGW7VfQxNLavjCXviqxT9S1MgbjMNHr6Sp5; async function main() { const provider new WsProvider(WS_URL); const api await ApiPromise.create({ provider }); formatBalance.setDefaults({ decimals: 10, unit: DOT }); const accountInfo await api.query.system.account(ADDRESS); const { nonce, data } accountInfo; const free data.free; const reserved data.reserved; const frozen data.frozen || data.miscFrozen || data.feeFrozen || 0; const transferable free.sub(frozen); console.log(地址:, ADDRESS); console.log(交易序号 nonce:, nonce.toString()); console.log(自由余额 free:, formatBalance(free, { withUnit: DOT })); console.log(预留余额 reserved:, formatBalance(reserved, { withUnit: DOT })); console.log(冻结余额 frozen:, formatBalance(frozen, { withUnit: DOT })); console.log(可转账余额:, formatBalance(transferable, { withUnit: DOT })); await api.disconnect(); } main().catch(console.error);formatBalance.setDefaults({ decimals: 10, unit: DOT })这行我加了全局默认值因为不同链的精度不一样直接指定可以避免输出单位是 Planck 而不是 DOT。运行后输出类似地址: 15oF4uVJwmo4TdGW7VfQxNLavjCXviqxT9S1MgbjMNHr6Sp5 交易序号 nonce: 18 自由余额 free: 1.2345 DOT 预留余额 reserved: 0.0000 DOT 冻结余额 frozen: 0.0000 DOT 可转账余额: 1.2345 DOT如果还想查这个地址用sufficients参与了多少种资产可以进一步调用const accountInfo await api.query.system.account(ADDRESS); console.log(sufficients:, accountInfo.sufficients.toString());sufficients表示该账户持有几种非原生资产比如 AssetHub 上的 USDT、平行链上的 Token 等。它是判断账户是否存活的重要参考指标很多链上地址清理工具就是靠它判断资产是否归零。4. 查询资产信息从 Assets Pallet 到 ORML Tokens4.1 Assets Pallet官方资产模块的查询方式波卡生态里的资产有两套主流方案。一套是 Substrate 官方 Assets Pallet常见于 AssetHub原 Statemint/Statemine和中继链上的部分资产另一套是 ORML Tokens常见于 Acala、Bifrost 等平行链。先讲 Assets Pallet。Assets Pallet 的存储结构通常分三层assets.asset(assetId)资产全局信息包括发行量、管理员、最小持有量等。assets.metadata(assetId)资产的符号、名称、精度。assets.account(assetId, address)某个地址持有该资产的数量和冻结状态。下面的示例查询 AssetHub 上 USDT 资产信息。在 Polkadot 的 AssetHub 上USDT 的资产 ID 通常是1984DOT 是1USDC 是1337const ASSET_ID 1984; // USDT const assetInfo await api.query.assets.asset(ASSET_ID); const metadata await api.query.assets.metadata(ASSET_ID); console.log(资产详情:, assetInfo.toHuman()); console.log(元数据:, metadata.toHuman());metadata里能看到symbol、name、decimals等字段比如 USDT 的 symbol 是USDT精度是 6 位。再查指定账户的持仓const address 15oF4uVJwmo4TdGW7VfQxNLavjCXviqxT9S1MgbjMNHr6Sp5; const accountAsset await api.query.assets.account(ASSET_ID, address); console.log(持仓详情:, accountAsset.toHuman());输出类似{ balance: 1000000000, isFrozen: false, reason: Consumer }balance就是该地址持有的 USDT 数量因为 USDT 是 6 位精度所以1000000000对应 1000 USDT。isFrozen表示该账户是否被链上冻结比如涉及治理处罚或合规风控时会被置为true。4.2 ORML Tokens平行链常用资产方案的查询方式如果目标链是 Acala、Karura、Bifrost 这类基于 ORML 的平行链查询路径完全不一样。ORML Tokens 用tokens.accounts这个双映射存储第一个参数是账户地址第二个参数是货币 IDCurrencyId。货币 ID 在不同链上的类型定义不同常见格式有两种枚举类型{ Token: ACA }、{ Token: KSM }结构化类型{ Token2: AUSD }、{ ForeignAsset: 0 }以 Acala 为例查询账户的 ACA 余额const address 你的 Acala 地址; const tokenAccounts await api.query.tokens.accounts(address, { Token: ACA }); console.log(ACA 账户信息:, tokenAccounts.toHuman());返回结构{ free: 1000000000000, reserved: 0, frozen: 0 }ORML Tokens 的三个字段含义和 System Pallet 类似free是可用数量reserved是预留数量frozen是冻结数量。计算可转账余额同样用free.sub(frozen)。查询总发行量const totalIssuance await api.query.tokens.totalIssuance({ Token: ACA }); console.log(ACA 总发行量:, formatBalance(totalIssuance, { decimals: 10, withUnit: ACA }));4.3 整体示例解析资产详情与账户持仓我把 Assets Pallet 的查询组合成一个完整脚本方便你将 AssetHub 和中继链资产一网打尽。如果你同时关心多个资产可以先拉出资产 ID 列表再逐个查询const { ApiPromise, WsProvider } require(polkadot/api); const { formatBalance } require(polkadot/util); const WS_URL wss://polkadot-asset-hub-rpc.polkadot.io; const ADDRESS 15oF4uVJwmo4TdGW7VfQxNLavjCXviqxT9S1MgbjMNHr6Sp5; const ASSET_IDS [1, 1984, 1337]; // DOT, USDT, USDC 按实际存在与否调整 async function main() { const provider new WsProvider(WS_URL); const api await ApiPromise.create({ provider }); for (const assetId of ASSET_IDS) { const metadata await api.query.assets.metadata(assetId); const asset await api.query.assets.asset(assetId); const account await api.query.assets.account(assetId, ADDRESS); const decimals metadata.decimals.toNumber(); const symbol metadata.symbol.toHuman(); const balance account.isEmpty ? 0 : account.balance.toString(); console.log(资产 #${assetId} ${symbol}:, formatBalance(balance, { decimals, withUnit: symbol })); } await api.disconnect(); } main().catch(console.error);这里有个重要细节account.isEmpty。如果某个地址从未持有该资产查询结果是一个空值直接调用account.balance会抛错。所以先判断isEmpty再取值是最稳妥的写法。如果是查询平行链上的 Token 列表可以用 keys 方法遍历const tokenKeys await api.query.tokens.accounts.keys(address); console.log(tokenKeys.map((key) key.args[1].toHuman()));注意args[1]是货币 ID 参数具体输出结构根据链的定义会有所差异。5. 常见问题与排查技巧实录5.1 连接异常与超时公共节点不稳定怎么处理我在实际开发中遇到最多的报错就是 WebSocket 连接中断。公共节点的连接数有限高峰期容易断线或者长时间运行后连接被服务端回收。解决的方案是处理连接断开事件并重连。const provider new WsProvider(WS_URL); provider.on(disconnected, () { console.log(节点连接断开等待重连...); }); provider.on(error, (err) { console.error(节点错误:, err.message); });如果脚本是常驻进程我建议额外加一个心跳检测定期调用api.rpc.system.health()连续三次失败就手动provider.disconnect()再重新连接。这比单纯依赖官方库的自动重连更可靠尤其是跑长期监控任务时。5.2 查询结果出现 undefined 或类型不匹配如果你查的存储项在链上不存在返回结果往往是空值或undefined。最常见的原因有三个资产 ID 不存在例如某一资产在该链上根本没注册assets.asset(assetId)返回空值。查询的 Pallet 名称写错Polkadot.js 的api.query路径跟 Runtime 里的 Pallet 名称完全对应如果链上没有这个 Pallet调用时会直接报错。链版本太旧导致类型未注册某些链的 Runtime 还没升级但你的 Polkadot.js 库版本太新类型解码不兼容。针对资产这种情况先判断返回是否为空const asset await api.query.assets.asset(ASSET_ID); if (asset.isEmpty) { console.log(资产不存在或尚未注册); }5.3 批量查询地址的最佳实践如果你有一个地址列表需要查询余额千万不要用for...of串行查询太慢了。推荐你用api.queryMulti一次批量拉取或者用Promise.all做并发控制。用queryMulti的方式const addrList [地址1, 地址2, 地址3, 地址4, 地址5]; const queries addrList.map((addr) [ api.query.system.account, addr ]); const results await api.queryMulti(queries); results.forEach((info, index) { const free info.data.free.toString(); console.log(${addrList[index]}: ${free}); });queryMulti会将多个查询合并成一次 RPC 批处理请求减少网络往返。如果涉及不同存储项也可以混合传入只要每项都是[查询方法, 参数]的元组结构。如果非要并发调用记得控制并发数量。我自己习惯在工具类里写一个简单的限流函数每批最多同时发 20 个请求避免把节点连接数打满。否则公共节点会返回Rate limit exceeded错误反而更慢。5.4 真实项目中的几个小建议最后补充几点我做过多个波卡项目之后沉淀下来的经验。第一生产环境不要在主线程里直接创建 API 实例后不销毁。每个api实例都会占用一个 WebSocket 连接创建多了会导致文件描述符耗尽。正确的做法是全局维护一个单例项目全程复用进程退出时调用api.disconnect()。第二务必关注链的 Runtime 升级。Substrate 链支持无分叉升级也许今天查询的存储字段还是miscFrozen明天升级后就变成frozen了。我建议每隔一段时间跑一次const runtimeVersion await api.rpc.state.getRuntimeVersion(); console.log(Runtime specVersion:, runtimeVersion.specVersion.toString());把这个值记录下来和链上浏览器里的版本对比一旦发现升级及时更新依赖包并回归测试自己的查询逻辑。第三类型安全很重要。如果你的项目用 TypeScript可以使用polkadot/typegen根据链的 Metadata 生成类型定义这样查询结果会有完整的类型提示能提前发现字段变化。第四链上查询永远基于 RPC 节点的当前状态。如果你的查询结果需要保证一致性比如交易后再查询先确认你连的节点同步到了哪个区块调用await api.rpc.chain.getFinalizedHead()拿到已最终化的区块哈希然后再基于这个区块做查询。这样可以避免因为节点同步进度不一致而读到不同状态。链上状态查询是波卡开发最基础的技能也是往后做转账、解析事件、构建索引器的基础。把这套查询逻辑吃透后面无论是给钱包做余额展示还是给数据产品做链上分析都会顺手很多。希望这篇文章能帮你少走一些弯路踩过的那些坑你就不用再踩了。