前端与 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 🇨🇳🌞🌑🌦🌬💫✨
评论