☰
用@polkadot/api构建Polkadot链上操作CLI工具实践
2026/10/6 3:52:03 网站建设 项目流程

做区块链开发的朋友应该都有这个体验:链上数据查询,明明是很简单的需求,却总是被折腾得够呛。打开 Subscan 查个地址余额,要等页面加载半天;写 Shell 脚本调 RPC,又要处理 JSON 组装和返回解析。我用 Polkadot API 折腾了一个命令行工具,把所有常用的链上操作收敛成几条简洁的命令,例如查询余额、转账、订阅实时变动,体验比网页端舒服多了。这篇文章完整记录了这个 CLI 应用从设计到落地的全过程,包含核心代码、参数计算、常见坑点,适合正在学 Substrate 开发或者想提升链上操作效率的开发者参考。

1. 项目概述与需求拆解

1.1 这个 CLI 工具解决什么问题

先说背景。我平时维护几个 Substrate 链上的地址,经常要做三类重复操作:查某个地址的余额和锁仓情况、给别人转账、盯住某个地址的资金变动。这些操作在网页端都能做,但网页端有几个痛点。

第一,加载慢。每次都要重新建立连接,拉取全量页面数据,其实底层的 RPC 调用就那么一两个,大部分时间都耗在了前端渲染上。第二,没法脚本化。换个地址要重新复制粘贴,想批量检查十几个地址的余额,网页端基本不可用。第三,自动化困难。你想在 CI 脚本里跑一个余额检查,或者写个定时任务监控某个地址的大额变动,网页端完全帮不上忙。

所以这个 CLI 工具的核心目标就一句话:把链上常用操作变成可以在终端里直接执行、可以写进脚本的命令。项目定位是"简单"而不是"完整",所以第一版只做了三个核心命令:查询余额、转账、订阅余额变化。后续要扩展批量操作,也只是在现有框架里加命令而已。

1.2 技术选型:为什么选 @polkadot/api + Node.js

这个项目最核心的依赖就是 Polkadot API 库。现在社区里操作 Substrate 链主要就两个选择:一个是 Polkadot JS 官方维护的 @polkadot/api,TypeScript 编写,API 设计完善,查询、交易、订阅都覆盖了;另一个是直接用底层 RPC 协议自己封装 HTTP/WebSocket 请求。

我选 @polkadot/api 的原因很直接。第一,它把所有存储读取和交易构造封装成了类型安全的方法,不用自己去拼 JSON-RPC 请求;第二,它对链的运行时元数据做了动态适配,哪怕链的 runtime 升级了,只要库是最新版,基本不用改代码;第三,它内置了 Keyring 密钥管理,签名、导入助记词都很方便。

CLI 框架我选了 commander,它是 Node.js 生态里最老牌的命令行参数解析库,用法接近 Python 的 argparse,声明式地定义选项和子命令,对我们这种小工具来说足够了。没有选 yargs 是因为功能更重、学习成本更高;没有选 meow 是因为它太轻量,子命令和帮助信息需要自己维护。commander 恰好落在中间。

2. 环境准备与项目初始化

2.1 初始化 Node.js 项目与依赖安装

我假设你已经装好了 Node.js,版本建议 18 以上。之所以推荐 18,是因为 @polkadot/api 近期版本在 Node 16 上会出现一些 WebSocket 和 fetch 相关的兼容问题,而 Node 18 把这些 API 都稳定了,用起来省心。

初始化项目很简单:

mkdir polkadot-cli cd polkadot-cli npm init -y npm install @polkadot/api commander dotenv chalk

说明一下几个依赖的用途。@polkadot/api 是核心库不用多说;commander 用于解析命令行参数;dotenv 用来加载 .env 配置文件,把助记词之类的敏感信息塞进环境变量;chalk 给终端输出加颜色,纯粹为了体验,你嫌麻烦可以不加。

安装完成之后,在 package.json 里加一段 bin 配置,声明这是命令行工具:

"bin": { "polkadot-cli": "./index.js" }

然后执行 npm link,把命令链接到全局,之后在任何目录都能直接敲 polkadot-cli 了。这一步很多人会漏掉,没做的话命令名会被提示 not found。

2.2 网络连接配置:从公共节点到本地节点

连接 Polkadot 网络,核心就是创建 ApiPromise 实例。这里有个重要的选择:用什么类型的连接方式。@polkadot/api 支持 WsProvider 和 HttpProvider,前者是 WebSocket 长连接,支持订阅,后者是一次性 HTTP 请求。由于我们要做实时订阅,必须用 WebSocket。连接公共节点用的是官方的 wss://rpc.polkadot.io,也可以用第三方节点如 wss://polkadot.api.onfinality.io。

代码是这样的:

const { ApiPromise, WsProvider } = require('@polkadot/api'); const provider = new WsProvider('wss://rpc.polkadot.io', 1000); const api = await ApiPromise.create({ provider });

第二个参数 1000 是连接超时时间,单位毫秒。实测公共节点在全球不同地区延迟差异很大,如果你经常遇到连接超时,可以把这个值调到 5000,或者换一个离你近的节点。有一点要注意,公共节点为了防滥用,往往有连接频率限制,如果你要做高频批量查询,最好自建轻节点或者用付费节点服务,这个后面在踩坑部分细说。

选择连接到哪条链,直接由节点的 WebSocket 地址决定。想切换测试网络,把地址换成 wss://westend-rpc.polkadot.io 就行。为了让工具在不同网络之间切换更方便,我把节点地址做成了环境变量:

# .env POLKADOT_RPC_URL=wss://rpc.polkadot.io

然后在代码里用 process.env.POLKADOT_RPC_URL 读取。这么做的理由是,命令行工具的接入网络不应该被硬编码,开发调试和本地测试切换网络越方便,越不容易因为误操作在主网上传错交易。

提示:ApiPromise.create 初始化时会自动拉取链的元数据并构建类型注册表,这个过程通常需要一两秒。如果是首次连接,网络慢的话可能更久,别误以为程序卡死了。

3. 核心功能设计与实现

3.1 命令行框架搭建:commander 的用法

我先把命令行框架搭起来。commander 最常用的写法是用 Command 对象声明子命令:

#!/usr/bin/env node const { program } = require('commander'); program .name('polkadot-cli') .description('A simple CLI tool for Polkadot chain operations') .version('0.1.0'); program .command('balance') .description('Query the balance of an address') .argument('<address>', 'SS58 address to query') .option('-n, --network <network>', 'network name', 'polkadot') .action(async (address, options) => { await queryBalance(address, options.network); }); program.parse();

这段代码做了几件事:定义了 balance 子命令,要求必须传入一个地址参数,还附带一个 -n 选项用来选择网络。commander 会自动生成帮助文档,你运行 polkadot-cli --help 就能看到完整的命令说明,这对命令行工具的可维护性很重要,不用自己维护文档。

主流程里,每个子命令的 action 回调都是异步函数,内部再调用具体的业务逻辑。由于 Node.js 的顶层 await 在 CommonJS 模块里不可用,所以我把 index.js 的主入口写成 async main() 然后调用。这是 Node.js 写 CLI 最常见的模式,避免回调嵌套导致层级太深。

3.2 查询账户余额与格式化

查询余额最原始的方式是直接用链的存储查询:

const accountInfo = await api.query.system.account(address); const { free, reserved, miscFrozen, feeFrozen } = accountInfo.data;

这里的 accountInfo.data 返回的是一个结构体,free 是可自由支配的余额,reserved 是被锁定的余额,miscFrozen 和 feeFrozen 分别代表因普通锁和交易费锁定的部分。从这些字段推导"实际可用余额"很容易出错,因为不同场景要减不同字段。

所以我更推荐用 @polkadot/api 提供的 derive 接口:

const { freeBalance, reservedBalance, availableBalance, lockedBalance } = await api.derive.balances.account(address);

derive 接口直接把可用的、锁定的、预留的余额都算好了,省掉自己手动拼接冻结逻辑的麻烦。对于 CLI 工具来说,清晰展示 freeBalance、reservedBalance、lockedBalance 三个值就够了,用户一眼能看懂这个地址的资金结构。

接下来是格式化问题。链上存储的余额是一个大整数,单位是 Planck,也就是链上的最小单位。Polkadot 的精度是 10 位小数,所以 1 DOT = 10^10 Planck。如果你直接把 free 的值打出来,会得到一长串数字,比如 1234567890123456,看不懂。

需要自己除以 10 的精度次方:

const decimals = api.registry.chainDecimals[0]; const formatted = Number(freeBalance.toString()) / 10 ** decimals;

注意这里有个精度陷阱:JavaScript 原生 Number 类型能安全表示的最大整数约等于 2^53,而 Polkadot 上的余额经常超过这个范围,所以链上 API 返回的值一律是字符串或者 BN 对象,不能直接当数字做运算。我的做法是用 @polkadot/api 自带的 formatBalance 工具类做字符串格式化,或者手动转换成浮点数展示。

只做展示的话,Number 转换就够了,但如果要在转账或者做数学运算,就必须全程使用 BN 类型,这一点非常容易踩坑,后面单独讲。

3.3 转账功能:签名、发送与确认

转账要用到交易构造接口。假设我们要从地址 A 转给地址 B 一定数量的 DOT,需要先创建 Keyring 实例,导入发送者的助记词或私钥:

const { Keyring } = require('@polkadot/api'); const keyring = new Keyring({ type: 'sr25519' }); const sender = keyring.addFromUri(process.env.SENDER_MNEMONIC);

关于密钥类型的解释:Polkadot 生态里常用的签名算法有 sr25519 和 ed25519 两种。默认是 sr25519,由 Schnorrkel 算法实现,官方钱包生成的地址大多是这个类型。如果你用别的工具导入了 ed25519 私钥,导入时就需要显式指定 type: 'ed25519'。导入错误是最常见的签名失败原因,报错信息往往很抽象,比如 Signature verification failed,十有八九是密钥类型不匹配。

构造交易:

const dest = keyring.addFromAddress(recipientAddress); // 或者直接用地址字符串 const amount = new BN('10000000000'); // 1 DOT,已换算成 Planck const tx = api.tx.balances.transfer(dest.address, amount); const signedTx = await tx.signAsync(sender); const hash = await signedTx.send();

这里有一个关键概念:tx 对象在被签名之前是一个未签名的 extrinsic,签名之后才变成可广播的交易。send 方法返回的是交易哈希,但它只代表交易已经进入了本地节点的交易池,不代表已经被确认打包。

要确认交易真正上链,需要订阅事件:

await signedTx.send(({ status, events }) => { if (status.isFinalized) { console.log('Transaction finalized in block', status.asFinalized); process.exit(0); } });

这里的 status 包含多个阶段:Ready、Broadcast、InBlock、Finalized。最稳妥的判断是等到 Finalized,也就是区块已经在链上完成最终性确认。我在第一版实现里只监听了 status.isInBlock 就宣布成功,后来发现这并不可靠——块被打包了但后续如果链发生重组,交易可能会被回滚。

另外,amount 参数在真实场景中不应该硬编码。用户输入的是 DOT 数字,比如 -a 1.5,需要先转换成 Planck 才能构造交易。我封装了一个转换函数:

function toPlanck(input, decimals) { const [whole, fraction = ''] = input.split('.'); const paddedFraction = fraction.padEnd(decimals, '0').slice(0, decimals); const wholeBN = new BN(whole).mul(new BN(10).pow(new BN(decimals))); const fracBN = new BN(paddedFraction || '0'); return wholeBN.add(fracBN); }

这个函数把字符串 "1.5" 拆成整数部分和小数部分,小数部分左补右删到指定精度,最后合成为一个 BN 大整数。用字符串解析而不是 parseFloat,是为了避免浮点数二进制表示带来的精度偏差。

3.4 实时订阅:监听账户状态变化

订阅功能依赖 WebSocket 长连接。@polkadot/api 对存储的实时订阅支持得很好,直接给存储查询方法传一个回调函数,它就会在值变化时自动触发:

await api.query.system.account(address, ({ data: { free } }) => { console.log(`Free balance: ${formatBalance(free, { decimals })}`); });

这个回调在链上该账户的任何余额变动时都会触发,无论是转账、质押还是交易费扣除。对于监控场景,比如盯住一个地址有没有大额资金进出,这个功能非常够用。

需要提醒的是,订阅状态下进程不会自然退出,所以我加了一个 SIGINT 事件处理,用户按 Ctrl+C 时优雅关闭连接:

process.on('SIGINT', async () => { await api.disconnect(); process.exit(0); });

不主动 disconnect 就退出进程,在大多数平台下也没问题,但偶尔会让 WebSocket 连接残留在半开状态,节点端会多消耗资源。优雅退出是一种习惯,写 CLI 工具时养成这个习惯成本很低,但能避免不少排查线上的小麻烦。

提示:如果你要同时订阅多个地址,每个订阅都会占用一个 WebSocket 通道。公共节点对单连接的并发订阅数有限制,订阅过多会被服务端强制断开。批量监控场景建议通过 api.queryMulti 一次性订阅多个账户,或者减少并发数。

4. 实操过程中的坑与排查记录

4.1 连接失败与节点选择

开发过程中遇到最多的问题就是连不上节点。第一次跑的时候,直接用的官方公共节点,结果等了十几秒就报错误:Unable to connect。排查后发现两个原因。

一是公司网络对 WebSocket 长连接的代理支持不友好,HTTP 请求能通但 WS 被拦了。这个问题在开发者群体里挺常见,尤其在走代理访问外网的环境下。二是 WsProvider 默认的连接超时时间比较短,公共节点在高峰期响应慢,还没连上就被判定失败。

解决办法是把超时时间调大,同时通过环境变量支持多节点备用。我在代码里实现了一个简单的连接重试逻辑:先试主节点,连不上就自动尝试备用节点,都不行再给出明确的错误提示。很多新手遇到连接失败会直接怀疑 API 库有问题,其实大部分时候是节点选择的问题。测试阶段建议优先用自建的本地节点或者同一地域的公共节点,网络链路更短,排障更快。

4.2 精度丢失与单位换算陷阱

这个坑我印象最深。第一版查询余额的时候,直接写了 balance.toString() 然后把结果输出,出来一长串数字,我当时还觉得挺正常。直到有一天我需要算这个地址的美元价值,想把它转成 Number 类型乘以价格,结果页面直接显示了一个莫名其妙的数字。查了半天才发现是 JavaScript 大整数精度丢失。

Polkadot 的最小单位是 Planck,一个地址的余额动辄是 10^20 级别,远超 Number.MAX_SAFE_INTEGER。用 Number 去转,精度直接断在某个位数上,出来的结果和真实值差之甚远。正确做法是永远用 BN 类型做运算,只在最终展示的时候才转成字符串或者浮点数。@polkadot/api 提供了 formatBalance 工具函数,可以直接把 BN 值格式化成带单位的字符串,我后来就统一用它输出:

const { formatBalance } = require('@polkadot/util'); formatBalance(balance, { decimals: 10, withUnit: 'DOT' }); // 输出如 '1.2345 DOT'

这个函数还支持设置 withSi 参数,决定是否显示单位前缀,用起来灵活。凡是涉及余额计算的场景,我一律建议用 BN,别图省事转 Number。

4.3 密钥管理与安全注意事项

CLI 工具绕不开密钥管理的问题。我一开始偷懒,直接把助记词写在代码里测试转账,结果测试完忘了删,后面有一次把代码传到远程仓库时才意识到危险。幸好是测试币,没造成实际损失,但这个教训很深刻。

正确的做法是,助记词和私钥一律放环境变量或者操作系统的密钥链里,代码里只读取 process.env。之后我又加了一道保险:当命令行检测到当前网络是主网 Polkadot 时,会打印一行醒目的确认提示,让用户输入 Y 或者 N 确认。这个设计可能显得有点繁琐,但命令行工具方便的同时也危险,一个不留神就把主网资产转错了,多点确认没有坏处。

注意:助记词永远不要提交到 Git 仓库,哪怕仓库是私有的。一旦泄露,对方可以完全控制你的地址。建议在 .gitignore 里把 .env 文件排除掉。

4.4 交易失败判断与 nonce 管理

转账功能上线后,我发现偶尔有交易发送成功但最终没有上链的情况。第一次遇到时很困惑,因为在节点上是能看见交易的,广播后 status 也经历了 Ready 和 Broadcast,但就是没有最终的 Finalized。后来定位到是 nonce 冲突导致的。

解释一下 nonce,它是链上交易计数器,每个发送者在每个地址上的交易必须按顺序递增。如果你连续快速发送两笔交易,第一笔还没上链,第二笔的 nonce 可能还是旧值,就会导致第二笔被节点拒绝。公共节点的交易池处理策略也会影响这一点。我的解决方案是,在发送交易前显式查询当前 nonce:

const nonce = await api.rpc.system.accountNextIndex(sender.address); const signedTx = await tx.signAsync(sender, { nonce });

这样每次发送都用节点返回的最新 nonce,避免撞车。另外,交易失败还有一种常见情况是手续费不足。Polkadot 的交易费是链下估价、链上结算的机制,如果发送者的可用余额小于转账金额加上预估手续费,交易会被回滚。这个用 derive 接口的 availableBalance 做余额校验就能提前拦截。

5. 常见问题速查表

把开发过程中遇到的高频问题整理成一张表,方便你遇到问题时直接对照。

问题现象常见原因解决办法
连接失败 / 超时节点负载高、网络代理拦截、超时设置过短换节点、调大超时、加连接重试
WebSocket 断开频繁连接数过多、节点限流降低订阅数、使用 api.queryMulti、自建节点
Signature verification failed密钥类型不匹配 sr25519 / ed25519导入时显式指定正确的 Keyring type
余额显示为一长串大数字没做单位换算用 chainDecimals 和 formatBalance
余额计算后数值错误Number 类型精度丢失全程使用 BN 做整数运算
交易广播后长时间无 Finalizednonce 冲突或被交易池丢弃显式设置 nonce,检查交易是否有效
交易被打包后又被回滚可用余额不足,手续费不够发送前用 availableBalance 预检查
命令行命令提示 not found没执行 npm link 或 bin 配置错误检查 package.json 的 bin 字段和 npm link
查询多个地址时响应变慢每个查询都是独立 RPC 请求用 api.queryMulti 批量查询

如果只是学习和测试,强烈建议先用 Westend 测试网络跑一遍完整流程。测试网络的币可以免费申请,操作流程和主网完全一致,唯一区别是节点地址不同。先用测试网络把工具调试到稳定,再切换到主网使用,能在最大程度上避免资产损失。

这个工具我用到现在,最深的感受是,CLI 应用把链上操作的摩擦降到最低。以前查余额要打开浏览器、等页面、复制地址,现在一条命令搞定。后续我还打算加批量查询和导出 CSV 的功能,现在这个框架已经足够支撑。如果你也在做 Substrate 相关的开发,建议直接拿 @polkadot/api 把常用的操作封装成小命令,日常效率提升非常明显。

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

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

立即咨询