前端与 Substrate 区块链交互
当我们说前端与区块链交互, 我们在说什么?
这里首先需要理解 2 个知识点:
区块链(blockchain), 准确地说指的是一种数据存储结构, 即以链(chain)的形式串联起来的一堆区块(block)
区块链网络, 一种分布式网络, 网络中的每一个节点(node), 都是一台装有并运行节点客户端(node-clint)的计算机, 一个完整的节点客户端(node-client) 包含一个区块链存储数据结构, 和用于跟其它节点通讯以及达成共识的外围程序.
具体如下图:
区块链上每个节点都"实时"与其它节点保持同步一致, 内部维护区块链数据必须全网一致, 这就是所谓达成**"共识".**
因此, 一个单独的完整区块链节点, 其具备的功能, 与任意其它节点具备的功能是一致的, 这里我们假定该网络中所以节点都是可信的.
💡 因此, 所谓与区块链交互, 从前端开发的角度来说, 就是与区块链网络中的某一个(或者一组)节点进行交互.
从前端的角度来说, 区块链网络中的某个节点, 就类似传统的开发中的"后端" server.
当然, 作为官方的前端 app, 区块链网络应提供一组可信的,性能优越的机器作为交互节点.
Substrate 的基本结构 (可选阅读)
这一部分是可以跳过, 主要是为了让前端的伙伴了解一下后端大致开发的结构, 不看也没关系 😂
我们的 cambio network 是基于 substrate 开发, substrate 的一个重要特性就是模块化. 一个区块链可能包含很多功能, 比如治理/投票/质押/众筹等等, 这些模块在 substrate 内部被称为 "pallet".
一个典型的 pallet 的结构都是固定的, 包含以下:
常量constants, 比如质押模块可能会设定一个 min_staking_amount 常量, 用于规定最小质押额度, 常量经常用于定义pallet的配置(Configuration)
储存值storage, 这个是区块链数据中的主要成分. 一个pallet的storages, 就代表了这个pallet要往链上存储什么数据.
还是以质押为例, 比如当某个用户质押了一笔金额, 那个这个pallet就会将一个(AccountId, Staking_amount, Block_number)元组存入链上. 这个元组就称为一个storage, storage有几种形式:
单值形式: 单个的类型值, 比如数字, 元组, 数组集合等
map键值对: 即一个 Key:Value 形式
D-map 双键值对: 一个 (Key1, Key2):Value 的形式
N-Map键值对: 即多键的形式
以上的形式都在 Polkadot.js 里有对应的处理 API.
Call调用: 即pallet的方法, 也就是pallet对外的执行接口, (内部接口不在讨论之列), 比如质押模块可能提供:
Staking(AccountId, Amount) 用于用户质押一定金额的Call;
Revoke_Staking(AccountId, Amount)用于撤回之前质押的Call;
一个 Call 在波卡系中, 也被称为一个**Extrinsic, 调用并执行一次Call, 有时也被称为"发起一笔交易".**
Event事件, 当一个 Call 执行后会可能会对执行情况以事件的形式报告出来, 比如质押模块中Staking Call 执行后, 如果成功可能会触发一个"StakingSuccessed(AccountId, Amount, Block_number)" 事件, 其事件中包含用户 id, 金额, 和写入的区块值.
Error错误, 当 Call 执行出错, 则会类似事件一样报错, 同样以 Staking Call 为例子, 如果用户余额只有 10, 但是却去质押 100, 那么执行中会报错" InsufficientBalance(AccountId, Balance)".
大致这 5 个元素在 Pallet 的关系和作用如下:
Pallet_staking: { { Constants: { //不可变的常量, 节点启动时就写入链上,除非整个链升级否则无法更改
min_staking_amount: u32 ..... } Storages: { // 可变存储量, 等价于整个链的各种state, 是链的主要数据 // 在运行中通过Call中的方法来CRUD // 任何时刻的state, 都会被区块链以区块的形式存储下来, // 因此, 在区块链中所有state的历史变化都是可查的 Record: (Acc, u32, u64), .... } Calls: { //类似一个实例化的interface, 主要用于操作Storage // 一般会返回一个Result枚举类型, T或者Error的一种, // T是泛型, 类似typescript中的Any, 但实际实例化时, 会给T确定一个类型 Staking(Acc, u32) -> Result<T, Error> .... }
Events: { //本质是一个枚举, Call运行中或运行完毕dispatch一个或者多个事件 // 虽然不是强制, 但是Call运行完毕dispatch一个事件则是最佳做法 //类似subquery等数据工具, 就非常依赖链上的Event来进行历史采集 StakingSuccessed(Record), .....
}
Errors: { // 本质也是一个枚举, 一个Call要么只能返回一个T // 或者dispatch一个Event, 要么只能返回Error InsufficientBalance(Acc, Balance) }
}
复制代码
总结一下:
Storage是链上主要的数据, 代表链从创世区块以来的状态, 每个区块都记录着零个或者多个 storage 的最新状态
外部通过调用Call中定义的方法, 来 CRUD 这些Storage
Call中的方法在操作Storage时, 可能会报错Error, 也可能会发出一个事件Event
Constants是初始化就定义好的不可变常量, 常常用于定义pallet的配置, 除非升级链, 否则不会变动.
如果用一句话综合一下, 一个Pallet要实现的就是:
💡 在 Constants 设定的配置条件下, 使用 Call 去操作 Storage, 来修改链的状态, 中间可能触发 Error 或者 Event
当然除了以上 5 个, pallet还会包含其他内容, 但基本对于前端是无须知道的.
前端如何与 substrate 节点通讯
一个完整的 substrate 开发的区块链节点客户端会暴露一组 JSON-RPC API 接口, 可以使用 HTTP 或者 WebSocket 方式来访问.
这个 JSON-RPC API 暴露的数据接口内容, 每个不同链都不一样, 但都遵循规定的格式和约束, 就是所谓metadata. 一个链的metadata包含了区块链网络给外部访问的所以接口和数据.
这些数据和接口, 通常可以用一个 json 文件来描述, 一个典型的metadata结构如下:
{ "magicNumber": 1635018093, "metadata": { "v13": { "modules": [ .... // 主要关注的部分, 详细请见下文 ], "extrinsic": { "version": 4, "signedExtensions": [ "CheckSpecVersion", "CheckTxVersion", "CheckGenesis", "CheckMortality", "CheckNonce", "CheckWeight", "ChargeTransactionPayment" ] } } }}
复制代码
其中前端主要关注的就是 "modules"部分, 这里的"modules", 就是指的是前面所说的 pallets.
一个典型的module内容, 就包含了前面所说的pallet的 5 大要素:
{ "name": "TemplateModule", "**storage**": { "prefix": "TemplateModule", "items": [ { "name": "Something", "modifier": "Optional", "type": { "plain": "u32" }, "fallback": "0x00", "docs": [] } ] }, "**calls**": [ { "name": "do_something", "args": [ { "name": "something", "type": "u32" } ], "docs": [ " An example dispatchable that takes a singles value as a parameter, writes the value to", " storage and emits an event. This function must be dispatched by a signed extrinsic." ] }, { "name": "cause_error", "args": [], "docs": [ " An example dispatchable that may throw a custom error." ] } ], "**events**": [ { "name": "SomethingStored", "args": [ "u32", "AccountId" ], "docs": [ " Event documentation should end with an array that provides descriptive names for event", " parameters. [something, who]" ] } ], "**constants**": [], "**errors**": [ { "name": "NoneValue", "docs": [ " Error names should be descriptive." ] }, { "name": "StorageOverflow", "docs": [ " Errors should have helpful documentation associated with them." ] } ], "index": 8 },
复制代码
这里有一个完整的metadata文件.
meta.json
可以看到, 一个module内就包含了每个 pallet 的名称,记录着每个pallet的 storage, calls, events, constants, errors.
所以, 读取到这个链的metadata, 就会知道这 Substrate 链提供了什么接口可供调用。当然, 对于我们自己开发而言, 只需知道必要的几个 Call/Storage/Event/Error 即可.
Polkadot.js 交互基础
polkadot.js 是波卡官方提供用来与 substrate 链交互的 js 库, 对各种类型,结构和调用进行了封装, 具体来说, 有 3 个主要的基础封装:
api.tx.<pallet>.<call> 来调用Call, tx 是 transaction 的缩写, 即交易, 调用一个 Call, 通常也被称为”发送一个交易”, 一般而言, 交易都是需要签名的, 签名环节请见后文
api.consts.<pallet>.<const> 来读取 pallet 中的常量
api.query.<pallet>.<name> 来读取 pallet 存储storage值
大致上, polkadot.js 与链上交互分为几个步骤:
连接到节点
const { ApiPromise, WsProvider } = require('@polkadot/api'); // Construct const wsProvider = new WsProvider('ws://127.0.0.1:9944'); // 如没有运行 node-template,也可试连到波卡主网上: `wss://rpc.polkadot.io`. const api = await ApiPromise.create({ provider: wsProvider });
复制代码
读取元数据, 一般性这只是一次性的可选动作, 后端完成后, 一般会自己导出一个 json 文件给到前端使用, 如果前端自行读取, 则:
const { magicNumber, metadata } = await api.rpc.state.getMetadata(); console.log("Magic number: " + magicNumber); console.log("Metadata: " + metadata.raw);
复制代码
基础业务逻辑处理
读取常量:
// api.consts.<pallet 名称>.<常量名称>. 比如: const main = async() => { const existentialDeposit = await api.consts.balances.existentialDeposit }
复制代码
// api.query.<pallet 名称>.<存储名称>. 比如: const main = async() => { const acct = await api.query.system.account(alice.address); }
复制代码
//调用`call`( 或者说 **发送交易**) await api.tx.balances.transfer(dest.address, amt) .signAndSend(src, res => { console.log(`Tx status: ${res.status}`); });
复制代码
批量处理
同时发多个查询可同时发多个查询,而不是一条一条发
// Subscribe to balance changes for 2 accounts, ADDR1 & ADDR2 (already defined) const unsub = await api.query.system.account.multi([ADDR1, ADDR2], (balances) => { const [{ data: balance1 }, { data: balance2 }] = balances; console.log(`The balances are ${balance1.free} and ${balance2.free}`); });
复制代码
可以批量调用多个:
// Subscribe to the timestamp, our index and balance const unsub = await api.queryMulti([ api.query.timestamp.now, [api.query.system.account, ADDR] ], ([now, { nonce, data: balance }]) => { console.log(`${now}: balance of ${balance.free} and a nonce of ${nonce}`); });
复制代码
以上的开发模式有两点要注意: - 作查询时,传入一个 回调函数 (callback)。这是个订阅函数。你在这里更新你 react 的 state 的话,就不会出现为什么链上数据改了,而前端没有更新数据的问题。 - `unsub`:这个 `unsub` 是一个函数,用来取消这个订阅的。如果是 react/前端开发,你在 `ComponentWillUnmount()`,或 `useEffect()` 里,就会 call 这个取消订阅函数。整个模式类似以下:
复制代码
useEffect(() => { let unsub = null; const asyncFetch = async () => { unsub = await api.query.pallet.storage( param, result => console.log(`Result: ${result}`) ); }; asyncFetch(); return () => { unsub && unsub() } }, [api, keyring]);
复制代码
订阅事件
// Create alice (carry-over from the keyring section) const alice = keyring.addFromUri('//Alice'); // Make a transfer from Alice to BOB, waiting for inclusion const unsub = await api.tx.balances .transfer(BOB, 12345) .signAndSend(alice, (result) => { console.log(`Current status is ${result.status}`); if (result.status.isInBlock) { console.log(`Transaction included at blockHash ${result.status.asInBlock}`); } else if (result.status.isFinalized) { console.log(`Transaction finalized at blockHash ${result.status.asFinalized}`); unsub(); } });
复制代码
交易流程及钱包交互
通常来说, 一个完整交易执行的流程是
发起交易 -> 输入交易信息 -> 用户对交易进行签名 -> 区块链确认签名正确 -> 执行交易 ->返回事件或者错误
复制代码
签名阶段, 主要有两个方式:
对于一个 web app 来说, 主要是通过钱包插件, 波卡中则是统一的 Polkadotjs-dapp 插件. 文档: https://polkadot.js.org/docs/extension
基本流程
首先需要安装 @polkadot/extension-dapp , 钱包里账户进入页面的方式被称为 “Inject”, 在前端中, 所有账户的提供方, 比如 polkadotjs 钱包, 狐狸钱包, 或者是页面自己新建的账户, 这些来源方统一称为”Provider”,
请求钱包授权, 只有获得钱包授权, 才能从钱包把账户”Inject”到页面中供 js 调用
import { web3Accounts, web3Enable, web3FromAddress, web3ListRpcProviders, web3UseRpcProvider} from '@polkadot/extension-dapp';
// 这一步在所有动作之前完成, const allInjected = await web3Enable('cambio network');
if (extensions.length === 0) { // 如果钱包插件没有安装, 或者没能获得授权, 应该做处理 return;}
复制代码
列出钱包中的账户
const allAccounts = await web3Accounts()
复制代码
本文为 SEP Creation 原创组文章, 作者彭亚伦
未经许可, 请勿转载.
IN RUST WE TRUST 🇨🇳🌞🌑🌦🌬💫✨
评论