Polkadot.js链上状态查询实战:账户余额与资产数据获取指南
2026/9/16 6:31:37 网站建设 项目流程

做波卡生态开发的朋友,不管你是写资产管理工具、做数据分析平台,还是给用户做余额展示页面,基本都会碰到同一个需求:把链上的账户余额和资产信息准确、高效地查出来。其实只要用过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 的版本会出现fetchWebSocket相关的兼容性问题,我现在项目里统一用 Node 20 LTS,跑得很稳。

2.2 选择 WebSocket 节点:别在主网上跑公开 RPC

Polkadot 主网的节点类型是 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去展示。如果要更严谨,还需要考虑 ED(Existential Deposit,最小存活余额),当余额低于 ED 时,链上会直接清理该账户,转出时系统会拒绝可能导致余额跌破 ED 的交易。

还有一个关键点:旧版 Substrate 中data里有miscFrozenfeeFrozen两个字段,新版统称为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 Tokens

4.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 通常是1984,DOT 是1,USDC 是1337

const 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里能看到symbolnamedecimals等字段,比如 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这个双映射存储,第一个参数是账户地址,第二个参数是货币 ID(CurrencyId)。

货币 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()拿到已最终化的区块哈希,然后再基于这个区块做查询。这样可以避免因为节点同步进度不一致而读到不同状态。

链上状态查询是波卡开发最基础的技能,也是往后做转账、解析事件、构建索引器的基础。把这套查询逻辑吃透,后面无论是给钱包做余额展示,还是给数据产品做链上分析,都会顺手很多。希望这篇文章能帮你少走一些弯路,踩过的那些坑,你就不用再踩了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询