Start

架构

image-20210415151111761

substrate客户端的基本构成:

  • 存储(storage):存储区块链的状态,使用简单而高效的键值对存储机制
  • 运行时(Runtime):定义处理块的逻辑,包括状态转换逻辑。运行时代码的编译方式有两种:
    • 编译为wasm并且存储在区块链上(使得forkless运行时升级成为可能)
    • 使用本机运行时编译为客户端的本机代码
  • 点对点网络(peer-to-peer network):使用rust的 libp2p 实现
  • 共识(consensus):substrate可以定制共识引擎,也提供一些建立在Web3 Foundation研究之上的共识机制
  • RPC(remote procedure call):使区块链用户可以与网络交互,substrate提供HTTP和WebSocket RPC服务器
  • Telemetry:由嵌入式Prometheus服务器公开的客户端度量

基本概念

Extrinsics

extrinsic指来自链外的信息,并且会被纳入区块中。分为三类:

  • inherents
  • 签名交易
  • 无签名交易

注: 执行函数时触发的事件不属于extrinsic

区块结构

substrate的每一个区块都是由一个区块头和一组extrinsic组成的,区块头包含区块高度、父区块哈希值、extrinsic根哈希值、链上状态根哈希值,以及摘要等信息。

Extrinsics 被打包到当前区块中并会被执行,每个extrinsic都在runtime进行了定义。 Extrinsic根哈希值主要有两个用途:

  • 它可以在区块头构建和分发完成之后,防止任何人对该区块头中所含extrinsic内容进行篡改
  • 它提供了一种方法,在只有区块头信息的条件下,可以帮助轻客户端快速地验证某一区块中存在某笔交易

Inherents

Inherent指的是那些仅能由区块创建者插入到区块当中的无签名信息,他们不会在网络上传播或存储在交易队列中

只要足够多的区块验证人认可该Inherent的合理性,那么这条Inherent就是有效的

签名交易

签名交易包含了签发该交易的账户私钥签名,这意味着此账户同意承担相应的区块打包费用

由于签名交易打包上链的费用在交易执行前就可以被识别,所以在网络节点中传播此类交易造成信息泛滥的风险很小

无签名交易

无签名交易意味着无人支付交易费用,这使得交易队列无法用有效的手段来防止其被滥用

无签名交易里缺失nonce字段来辅助识别交易执行顺序,从而难以防止重放攻击

少数交易能够安全使用不具签名的形式,前提是它们需要提供 SignedExtension 的自定义实现,来防止垃圾交易

Signed Extension

SignedExtension 是一个trait ,通过它可以使用额外的数据或逻辑来扩展交易

在交易执行之前,任何时候需要获取某笔特定交易信息时,都可以使用 SignedExtension 来实现。 因此SignedExtension 在交易队列中被大量使用

Runtime会使用 SignedExtension 提供的一些数据,比如用来计算可调用函数Call的交易费用

SignedExtension 还包含一个名为AdditionalSigned的字段,这个字段可存放任意可编码数据,因而能够在打包或者发送交易之前,被用来执行自定义逻辑

为了避免将可能失败的交易打包进区块中,交易队列还会定期调用 SignedExtension 的函数来验证即将进入区块的交易

SignedExtension 也可以用于验证无签名交易:通过实现*_unsigned 的一系列方法,来封装信息核验、防垃圾信息和重放保护等逻辑,供交易池使用

交易池

交易池包含所有在网络广播的,已被本地节点接收和验证的交易(签名和未签名的)

有效性

交易池检查交易是否有效(由Runtime决定):

  • 检查交易索引 (nonce) 是否正确
  • 检查帐户是否有足够的资金来支付相关费用
  • 检查签名是否有效

交易池还定期检查池内现有交易的有效性: 如果发现无效或过期的普通交易,该交易将被交易池删除

排序

如果交易是有效的,交易队列会将交易分为两组:

  • 就绪队列:包含所有可放到新的待处理区块中的交易。 对于随 FRAME 构建的 Runtime,所有交易必须严格遵循就绪队列中的顺序
  • 未来队列:包含所有可能在未来变成有效的交易。例如,一个交易可能有一个对其账户来说过高的 nonce 值,此交易将在未来队列中等待,直到之前的交易上传至区块链上

交易依赖关系

ValidTransaction 结构体定义了 requiresprovidespriority 参数来构建交易的依赖关系。 这个依赖关系允许交易池产生有效线性顺序的交易

对于用 FRAME 构建的 Runtime,节点基于不同账户对交易进行排序。 所有签名交易都需要包含一个交易索引 (nonce),该索引值在每次进行新的交易时都会递增1

FRAME 交易包括一个 provides 标签(值为 encode(sender ++ nonce) ),和 requires 标签(值为 encode(sender ++ (nonce -1)) if nonce > 1 )。来自单一发送人的所有交易将形成一个序列。

交易优先级

ValidTransaction 结构体中的 priority 决定了就绪队列中的交易顺序,priority 定义了当一个交易可解锁多个依赖交易时,所应有的线性排序

当某个节点成为下一个区块生成者时,它将在下一个区块把交易按优先级别从高到低排序,直到达到区块的长度限制

对于用 FRAME 构建的 Runtime,priority 定义为交易要支付的 fee (费用)。 例如:

  • 如果我们从不同的发送者那里收到 2 个交易(而且 nonce=0 时),我们通过 priority 来确定哪个交易更为重要,并优先把它打包进区块中
  • 如果我们从同一个发送方收到 2 个相同 nonce 的交易,那么只会有一个交易会被打包到链上。 我们使用 priority 来选择 fee 较高的交易,并把它储存到交易池中。

注意:交易池并不知道费用、账户、或签名,它只处理交易的有效性和 priorityrequiresprovides 参数这些抽象概念。 所有其他详细信息都是由 Runtime 通过 validate_transaction 函数定义的。

交易的流程

交易可以遵循两条路径:

我们的节点生成的区块
  1. 我们的节点会监听网络上的交易
  2. 每一笔交易都要经过验证,而有效的交易会被放入交易池
  3. 交易池负责对交易进行排序,并返回可被纳入区块的交易。在就绪队列中的交易将被用来打包到区块内
  4. 交易会被执行,而状态变化会存在本地内存中。来自就绪队列的交易也会在网络上传播给其他节点
  5. 构建好的区块会被发布到网络上,而网络上其他所有节点都会接收并执行该区块

注意:交易在区块生成时不会从就绪队列中被删除,只有在区块导入时才被删除,这是因为最新生成的区块有可能进不了规范链里

从网络接收的区块

该区块被执行后,整个区块要么成功,要么失败

交易有效性

validate_transaction 是在 Runtime 里被调用的,检查有效的签名和 nonce 并返回一个 Result

validate_transaction 只会个别地检查交易,所以它不会捡测到类似同一输出被使用两次的错误。

validate_transaction 并不会检查对模块的调用是否成功

validate_transaction 函数应专注于为交易池提供必要的信息,以便对交易进行排序和优先处理,并快速拒绝所有无效或过时的交易

账户

substrate用公私钥对来表示网络参与者

账户密钥

  • 密钥对代表一个账户,并可以控制资金

  • 账户密钥是通过泛型定义的,在 Runtime 中进行实例化

Stash密钥

Stash 账户的公私钥对,这个账户就像一个 “储蓄账户”,不应该用它进行频繁的交易。因此,应以最安全的方式来保存其私钥

Controller密钥

Controller 帐户的公私钥对,在 Substrate 的 NPOS 模型中,Controller 密钥会代表某个账户进行验证或提名

Controller 账户只需要支付交易费用,所以它只需要最少量的资金

会话密钥

  • 会话密钥是验证者用来签署和共识相关消息的 “热密钥”
  • 更改会话密钥的方式:通过Controller账户对会话公钥签名并创建一个证书,再将证书通过 extrinsic 广播
  • 会话密钥是通过泛型定义的,在 Runtime 中进行实例化

交易权重

  • 链可用的资源是有限的,包括内存、存储 I/O、算力、交易/区块大小和状态数据库的大小
  • 权重用于管理验证一个区块所用的时间,常用于限制存储I/O和算力
  • 区块中可包含的权重总量是有限的,并且可用的权重消耗通常也会受到交易费的限制
  • 最大区块权重应等于目标区块时间的三分之一,为区块构造分配、网络传播、导入和验证各分配三分之一

权重基础

对权重的计算应该满足:

  • 在被调用之前可计算。区块创建者在实际决定是否接受某个交易之前应该能够检查其权重
  • 本身消耗很少的资源。 如果计算交易的权重会消耗与执行交易消耗相似的资源,那这样就没有意义了。因此,权重计算应该比执行交易更轻量级
  • 无需访问链上状态即可确定使用的资源。权重有利于表示固定的度量或仅基于少量 I/O 的可调用函数参数的测量

如果权重十分依赖于链上状态,则可以:

  • 强制使用可调用函数可能消耗的权重上限。 如果可调用函数使用的强制权重上限与其下限差别只是很少,则可直接使用其权重上限而无需访问链上状态。 但是如果两者差别巨大,那么即使进行很少的交易,其经济成本也可能很大,这将破坏激励措施,并降低链上吞吐量
  • 要求有效权重作为参数传递到可调用函数中。 消耗的权量应基于这些参数,同时也应包含在调用时验证它们所花费的时间。 必须经过这一验证过程以确保权重参数准确对应于链上状态,如果对应不上,则报错

链下功能

使用预言机(Oracle)先对链下的数据作查询或处理,然后才将其提交到链上

预言机是一种外部服务,通常用于监听区块链事件,并根据条件触发任务。 当这些任务执行完毕,执行结果会以交易的形式提交至区块链上。 虽然这种方法可行,但在安全性、可扩展性,和效率方面仍然存在一些缺陷

因此substrate提供一些链下特性:

  • Off-Chain Worker (OCW) 执行长时间运行的和可能不确定的任务(如web请求、加解密、数据签名、随机数生成、cpu密集型计算、链上数据枚举/聚合等)
  • Off-Chain Storage 为substrate节点提供本地存储
  • Off-Chain Indexing 允许Runtime独立于OCW直接写入链下存储

image-20210415193130034

Runtime

  • Runtime 用于定义区块链的业务逻辑

  • 在 Runtime 中定义了用于表示区块链状态的存储项,同时也定义了允许区块链用户对该状态进行更改的函数

  • 为了能够提供无须分叉的升级功能,Substrate采用了可编译成 WebAssembly (Wasm)字节码的 Runtime 形式

  • FRAME 是Parity 的 Substrate runtime 开发系统, FRAME 定义了额外的 runtime 基础类型,并提供了一个框架,使得通过编写模块 (称为 “pallets”) 来构建 runtime 变得十分容易,每个 pallet 用于封装特定于该域的逻辑,这些逻辑可表示为一组存储项、事件、错误和可调用函数的集合

Runtime基本类型

核心原语

runtime必须提供给substrate其他层的最小化内容:

  • Hash:数据摘要, 通常是一个256位的数值
  • DigestItem
  • Digest:一系列 DigestItem 的组合, 它对当前区块中轻客户端所需知晓的所有信息进行了编码
  • Extrinsic:这种类型代表着一段来自链外、且被区块链认可的数据。 它通常包括一个或多个签名,以及某种编码指令(例如转移资金所有权或调用智能合约)
  • Header:包含了单个区块所有信息 (以加密或其它形式) 的类型。 它包括父区块哈希、存储根哈希和 extrinsic 根哈希、区块摘要及区块号
  • Block:基本上就是 Header 和一系列 Extrinsics 的组合,以及所使用的哈希算法说明
  • BlockNumber:一种类型,代表一个有效区块的祖先区块的总数量。 通常是32字节数值

FRAME原语

如果是通过Substrate FRAME搭建的runtime,还可以使用如下的FRAME原语:

  • Call: 通过extrinsic调用的可调用函数类型
  • Origin: 代表着函数调用方,例如可以是签名消息(交易)、无签名消息(区块链内生数据),或者runtime本身(根调用)
  • Index: 帐户的交易索引 (nonce) 类型, 存储交易发送方账户曾经发出的交易总数
  • Hashing:在runtime中使用的哈希系统 (算法)
  • AccountId: 用于在runtime中识别用户账户的类型
  • Event:代表runtime发出的事件类型
  • Version:代表runtime版本的类型

FRAME

Framework for Runtime Aggregation of Modularized Entities (FRAME) 是一组可简化 runtime 开发的模块(pallet)和支持库

FRAME 提供了一些与 Substrate Primitives 交互的帮助模块,而 Substrate Primitives 则提供了与核心客户端的交互接口

image-20210415195250082

Pallet

Pallets是一种可组合成为Substrate runtime的特殊Rust模块。每个pallet都拥有独立的逻辑,可修改相应区块链状态转换函数的特征和功能

Pallet架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1. 导入库和依赖项
// 此pallet支持使用任何带有`no_std`标志编译的Rust库。
use support::{decl_module, decl_event, decl_storage, ...}

// 2. Runtime配置Trait
// 所有runtime类型和常量都放在这里。
// 如果此pallet依赖于其他特定的pallet,则应将依赖pallet的配置trait添加到继承的trait列表中
pub trait Config: system::Config { ... }

// 3. Runtime事件
// 事件是一种用于报告特定条件和情况发生的简单手段,用户、Dapp和区块链浏览器都可能对事件的感兴趣。没有它就很难发现。
decl_event!{ ... }

// 4. Runtime存储
// Runtime存储允许在保证“类型安全“前提下使用Substrate存储数据库,因而可在块与块之间留存内容。
decl_storage!{ ... }

// 5. Pallet声明
// 此模块定义了最终从此pallet导出的"Module"结构体
// 它定义了该pallet公开的可调用函数,并在区块执行时协调该pallet行为
decl_module! { ... }

Substrate内置模块

汇总链接

Runtime宏

decl_storage!

在 pallet 中定义一个存储项目,存储项目的定义包括:

  • 数据类型,为下列其中一种:
    • StorageValue类型: rust-type
    • StorageMap类型: map hasher($hasher) rust_type => rust_type
    • StorageDoubleMap类型: doublemap hasher($hasher) rust_type, hasher($hasher) rust_type => rust_type
  • getter函数
  • 键类型及其哈希函数 (如果是map或double-map类型)
  • 存储的名称
  • 默认值

这些存储值可通过其后的add_extra_genesis模块在其创世区块中进行初始化

1
2
3
4
5
6
7
8
9
10
decl_storage! {
trait Storage for Module<T: Config> as MyModule {
// ...
}
add_extra_genesis {
build (|config| {
//...
});
}
}

API文档

decl_event!

通过实现 Event枚举类型来定义pallet事件,而宏中的每个事件类型都是Event枚举类型内的一个成员

API 文档

decl_error!

定义 pallet 在可调用函数中可能返回的错误类型 DispatchError 。宏自动为DispatchError 实现了 From<Error<T>> trait, 因此,DispatchError 能为特定的错误类型返回正确的模块索引、错误代码、错误字符串

API 文档

decl_module!

定义pallet中的可调用函数,在此宏中,pallet声明了一个名为 Module的结构体,以及一个名为 Call 的枚举类型。除了为 ModuleCall实现了各种辅助trait,如 CopyStructuralEqDebug以外,该宏还为Module实现了生命周期trait,如 frame_support::traits::OnInitializeframe_support::traits::OnFinalizeframe_support::traits::OnRuntimeUpgrade,和frame_support::traits::OffchainWorker

API 文档

construct_runtime!

用于构造Substrate runtime,将各个pallets集成到runtime。该宏声明及实现了各种不同的结构体和枚举类型,如RuntimeEventOriginCallGenesisConfig 等,同时也为这些结构体类型实现了不同的辅助trait

API 文档

  • Runtime 结构类型是为Substrate runtime而定义的
  • Event 枚举类型的成员变量是所有可发出事件的pallets,并且实现了辅助trait和编码/解码trait。Event 实现了TryInto<pallets::Event<Runtime>> trait,以从枚举类型中提取事件
  • Origin枚举类型是通过实现辅助traits来定义的,如 PartialEqCloneDebug等trait。 此枚举类型定义了是谁调用了extrinsic:NONEROOT还是由特定帐户签名调用
  • Call枚举类型由所有的集成pallet作为成员变量来定义的。 它包含每个集成pallet的数据和元数据,并通过实现frame_support::traits::UnfilteredDispatchable trait将调用重定向到特定pallet
  • 该宏定义了GenesisConfig 结构类型,并实现了sp_runtime:: BuildStorage trait以建立存储的创世配置
  • 该宏收集每一个pallet对 frame_support::unsigned::ValidateUnsigned 这个trait的实现, 如果没有任何一个pallet实现了 ValidateUnsigned trait,则所有的无签名交易都将被拒绝

parameter_types!

用于在构造runtime时声明参数类型,这些参数类型将赋值给各pallet的可配置trait关联类型。该宏使用get()函数返回的具体值,来替换掉结构体中指定的类型。 每个参数的结构体类型还实现了 frame_support::traits::Get<I> 这个trait,以将类型转换为其指定的值

API 文档

impl_runtime_apis!

通过RuntimeApiRuntimeApiImpl这两个结构体类型为客户端实现API

API 文档

add_crypto!

指定交给pallet管理的密钥对及其签名算法。该宏声明了三种结构体类型: PublicSignaturePair

  • Public 类型用于生成密钥对、签名和验证签名
  • Signature 类型用于在确定了签名加密方法情况下保存签名属性
  • Pair 类型用于使用种子生成一个公私密钥对

API 文档

impl_outer_origin!

用于为runtime构造一个 Origin结构体类型,它通常由construct_runtime!自动调用

API 文档

impl_outer_event!

用于在runtime时构造一个 Event 结构体类型, 它通常由 construct_runtime!宏自动调用

API 文档

impl_outer_dispatch!

用于实现一个元调用模块,以把调用分派给其它调用者,它通常是由 construct_runtime!自动调用的

API 文档

Runtime元数据

建立在 Substrate 上的区块链会暴露出元数据,以便能轻松与其交互。 元数据根据不同的 pallets来源被分隔成不同模块,对于每个模块,元数据都提供该模块对外暴露的 存储项、extrinsic 调用、事件、常量和错误的相关信息。 Substrate 会自动生成这些元数据,并通过 RPC 函数使它可被调用

可使用特定语言库或者与语言无关的HTTP和WebSocket API这两种渠道,来从Substrate节点中获取元数据

具体参考

Runtime执行流程

Substrate runtime的执行由Executive模块来协调,它负责调用区块链中包含的各种runtime模块

Executive模块对外暴露了 execute_block 函数,以实现如下功能:

  • 初始化区块
  • 执行extrinsics
  • 完结区块

验证交易

在区块开始执行前,检查签名交易的有效性

执行区块

只要有效交易的队列不为空,Executive模块就开始执行区块

初始化区块

区块初始化时,System模块和其他runtime模块都会首先调用其on_initialize 函数,把由模块定义的、需要前置的业务逻辑在交易执行前全部处理掉。 除System模块总是优先处理外,其余模块均按照在construct_runtime!宏里定义的顺序来执行

接下来是初始检查,该步骤将验证区块头中的父哈希是否正确,以及extrinsics trie的根是否囊括了所有的extrinsics

执行Extrinsics

按照交易优先级顺序执行每一个有效的extrinsic。 Extrinsics一定不能在rutnime逻辑中引起程序崩溃,否则系统将很容易受到用户攻击,而通过这种攻击,用户可不受任何惩罚地消耗计算资源

当extrinsic执行时,原有存储状态不会提前被缓存下来,修改将直接应用到存储上。 因此,在更改存储状态之前,runtime开发人员应进行所有必要检查,以确保extrinsic能执行成功。 一旦extrinsic在执行过程中失败了,存储更改将不能回滚

extrinsic执行时触发的事件也会写入存储。 因此,在完成所有待执行动作之前,不应该触发相关事件。 否则,倘若extrinsic在事件触发后才执行失败的话,该事件将不能回滚

完结区块

执行完所有队列中的extrinsic之后,Executive模块调用各模块的 on_idleon_finalize 函数来执行区块的最后业务逻辑

智能合约

概述

智能合约与Runtime开发的关系

Substrate Runtime 开发和 Substrate 智能合约是使用 Substrate 框架来构建 “去中心化应用” 的两种不同途径

智能合约

传统的智能合约平台允许用户在核心区块链逻辑之上发布额外的逻辑,为保证安全性,智能合约平台内建了一些安全防护手段,包括:

  • Fees:确保合约开发者在使用了区块链的计算和存储资源来执行智能合约之后付费,这样出块节点的资源就不会被他们滥用
  • 沙箱:一个合约无法直接修改核心区块链存储或其他合约的存储
  • 状态租赁:合约会因为占用了区块链的空间而需要为其付费
  • 回滚:合约可能有导致逻辑错误的情况,我们对合约开发者开发能力的期望很低,因此增加了额外的开销,以支持在交易失败时回滚整个交易
Runtime开发

Runtime 开发不向开发者提供智能合约所提供的那些保护或安全措施,相反,可以完全控制网络上每个节点运行的基本逻辑,也拥有修改和控制所有模块的每一个存储条目的完整权限

Substrate Runtime 开发的目的是为区块链提供精炼、高性能、和快速的节点。 它不提供任何保护,不提供交易回退的开销, 也不隐式地引入区块链上节点运行的收费系统

两者对比
智能合约 Runtime
对网络来说是天生安全 提供对整个区块链的底层访问权限
通过经济激励来防止滥用 没有任何原生的经济激励机制来抵御作恶
通过额外的计算开销来支持错误处理 没有内置安全措施带来的性能开销
开发门槛更低 开发者需要逾越一定的门槛

ink!智能合约

Substrate使用ink!作为其智能合约的语言,ink!是一个基于 Rust 的嵌入式领域专用语言(eDSL),专用于编写Contracts模块的 Wasm 智能合约,其设计宗旨是正确性、简洁性、高效性

ink! 设计上尽可能接近 Rust 编程语言,使用属性宏将标准的 Rust 结构标记为可理解的合约组件

1
#[ink(...)]

ink!的合约开发组件包括:

  • 事件 (Events)
  • 事件主题 (Event Topics)
  • 存储 (Storage)
  • 构造函数 (Constructor Functions)
  • 消息函数 (Message Functions)

因为是采用 Rust 编写,ink! 可以提供编译时的溢出/下溢安全保护

ink!语言基础

合约(Contracts)模块

合约模块为 Runtime 提供部署和执行wasm智能合约的能力

Wasm引擎

合约模块依赖于 Wasm 的沙盒接口,它定义了 Runtime 内可用的 Wasm 执行引擎(wasmi

功能

合约模块在智能合约的部署和执行上有许多合约开发者熟悉的功能以及一些新功能

合约账户

对 Substrate Runtime 来说,合约账户就像普通的用户账户一样;但是,除了普通账户所拥有的 AccountIDBalance 之外,合约账户还有相关的合约代码和一些持久的合约存储

部署合约

用 Contracts 模块部署合约需要两个步骤:

  1. 在区块链上存储 Wasm 合约
  2. 开启一个由新存储空间的新账户,与该智能合约挂勾

这意味着可以使用同一个 Wasm 代码初始化多个具有不同的构造参数的合约实例,从而减少区块链上 Contracts 模块所需的存储空间

合约调用

调用合约可以改变合约内的储存、创建新合约,或调用其他合约。 由于可以编写自定义 Runtime 模块,Contracts 模块也可以用合约账户直接异步调用那些 Runtime 函数

沙箱保护

Contracts 模块旨在供公共网络上的所有用户使, 这意味着合约只能直接修改他们自己所拥有的存储。 为了给底层区块链状态提供安全保障,合约模块实现了可逆交易,可回滚那些对存储进行改动而没有成功完成的合约调用

手续费

为了限制一次交易可使用的计算资源,合约调用需要收取手续费 (gas fee),在构造合约交易时,需要指定 gas 限额。随着合约的执行,gas 根据计算的复杂性逐步被消耗。如果在合约执行完成前达到 gas 限额,则交易失败,合约存储被还原,gas 费用 并不会 退还给用户;如果合约执行完成时还有剩余 gas,则在交易结束时退还给用户

存储租金

与 gas 限制了交易的计算资源类似,存储租金限制了合约在区块链存储中的占用率,合约账户按其使用的存储量所占的比例支付租金。 当合约可用余额低于某个限额时,合约账户会变成一个 “墓碑”,其存储被清空。 墓碑合约可通过提供能激活它的最少资金和被清除的数据,来重新激活合约