Start
架构
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
结构体定义了 requires
、 provides
和 priority
参数来构建交易的依赖关系。 这个依赖关系允许交易池产生有效线性顺序的交易
对于用 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
较高的交易,并把它储存到交易池中。
注意:交易池并不知道费用、账户、或签名,它只处理交易的有效性和
priority
、requires
和provides
参数这些抽象概念。 所有其他详细信息都是由 Runtime 通过validate_transaction
函数定义的。
交易的流程
交易可以遵循两条路径:
我们的节点生成的区块
- 我们的节点会监听网络上的交易
- 每一笔交易都要经过验证,而有效的交易会被放入交易池
- 交易池负责对交易进行排序,并返回可被纳入区块的交易。在就绪队列中的交易将被用来打包到区块内
- 交易会被执行,而状态变化会存在本地内存中。来自就绪队列的交易也会在网络上传播给其他节点
- 构建好的区块会被发布到网络上,而网络上其他所有节点都会接收并执行该区块
注意:交易在区块生成时不会从就绪队列中被删除,只有在区块导入时才被删除,这是因为最新生成的区块有可能进不了规范链里
从网络接收的区块
该区块被执行后,整个区块要么成功,要么失败
交易有效性
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直接写入链下存储
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 则提供了与核心客户端的交互接口
Pallet
Pallets是一种可组合成为Substrate runtime的特殊Rust模块。每个pallet都拥有独立的逻辑,可修改相应区块链状态转换函数的特征和功能
Pallet架构
1 | // 1. 导入库和依赖项 |
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 | decl_storage! { |
decl_event!
通过实现 Event
枚举类型来定义pallet事件,而宏中的每个事件类型都是Event
枚举类型内的一个成员
decl_error!
定义 pallet 在可调用函数中可能返回的错误类型 DispatchError
。宏自动为DispatchError
实现了 From<Error<T>>
trait, 因此,DispatchError
能为特定的错误类型返回正确的模块索引、错误代码、错误字符串
decl_module!
定义pallet中的可调用函数,在此宏中,pallet声明了一个名为 Module
的结构体,以及一个名为 Call
的枚举类型。除了为 Module
和 Call
实现了各种辅助trait,如 Copy
、StructuralEq
、 Debug
以外,该宏还为Module
实现了生命周期trait,如 frame_support::traits::OnInitialize
, frame_support::traits::OnFinalize
, frame_support::traits::OnRuntimeUpgrade
,和frame_support::traits::OffchainWorker
construct_runtime!
用于构造Substrate runtime,将各个pallets集成到runtime。该宏声明及实现了各种不同的结构体和枚举类型,如Runtime
、Event
、Origin
、 Call
、GenesisConfig
等,同时也为这些结构体类型实现了不同的辅助trait
Runtime
结构类型是为Substrate runtime而定义的Event
枚举类型的成员变量是所有可发出事件的pallets,并且实现了辅助trait和编码/解码trait。Event
实现了TryInto<pallets::Event<Runtime>>
trait,以从枚举类型中提取事件Origin
枚举类型是通过实现辅助traits来定义的,如PartialEq
、Clone
、Debug
等trait。 此枚举类型定义了是谁调用了extrinsic:NONE
、ROOT
还是由特定帐户签名调用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,以将类型转换为其指定的值
impl_runtime_apis!
通过RuntimeApi
和RuntimeApiImpl
这两个结构体类型为客户端实现API
add_crypto!
指定交给pallet管理的密钥对及其签名算法。该宏声明了三种结构体类型: Public
、Signature
和Pair
Public
类型用于生成密钥对、签名和验证签名Signature
类型用于在确定了签名加密方法情况下保存签名属性Pair
类型用于使用种子生成一个公私密钥对
impl_outer_origin!
用于为runtime构造一个 Origin
结构体类型,它通常由construct_runtime!
自动调用
impl_outer_event!
用于在runtime时构造一个 Event
结构体类型, 它通常由 construct_runtime!
宏自动调用
impl_outer_dispatch!
用于实现一个元调用模块,以把调用分派给其它调用者,它通常是由 construct_runtime!
自动调用的
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_idle
和 on_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!的合约开发组件包括:
- 事件 (Events)
- 事件主题 (Event Topics)
- 存储 (Storage)
- 构造函数 (Constructor Functions)
- 消息函数 (Message Functions)
因为是采用 Rust 编写,ink! 可以提供编译时的溢出/下溢安全保护
ink!语言基础
合约(Contracts)模块
合约模块为 Runtime 提供部署和执行wasm智能合约的能力
Wasm引擎
合约模块依赖于 Wasm 的沙盒接口,它定义了 Runtime 内可用的 Wasm 执行引擎(wasmi
)
功能
合约模块在智能合约的部署和执行上有许多合约开发者熟悉的功能以及一些新功能
合约账户
对 Substrate Runtime 来说,合约账户就像普通的用户账户一样;但是,除了普通账户所拥有的 AccountID
和 Balance
之外,合约账户还有相关的合约代码和一些持久的合约存储
部署合约
用 Contracts 模块部署合约需要两个步骤:
- 在区块链上存储 Wasm 合约
- 开启一个由新存储空间的新账户,与该智能合约挂勾
这意味着可以使用同一个 Wasm 代码初始化多个具有不同的构造参数的合约实例,从而减少区块链上 Contracts 模块所需的存储空间
合约调用
调用合约可以改变合约内的储存、创建新合约,或调用其他合约。 由于可以编写自定义 Runtime 模块,Contracts 模块也可以用合约账户直接异步调用那些 Runtime 函数
沙箱保护
Contracts 模块旨在供公共网络上的所有用户使, 这意味着合约只能直接修改他们自己所拥有的存储。 为了给底层区块链状态提供安全保障,合约模块实现了可逆交易,可回滚那些对存储进行改动而没有成功完成的合约调用
手续费
为了限制一次交易可使用的计算资源,合约调用需要收取手续费 (gas fee),在构造合约交易时,需要指定 gas 限额。随着合约的执行,gas 根据计算的复杂性逐步被消耗。如果在合约执行完成前达到 gas 限额,则交易失败,合约存储被还原,gas 费用 并不会 退还给用户;如果合约执行完成时还有剩余 gas,则在交易结束时退还给用户
存储租金
与 gas 限制了交易的计算资源类似,存储租金限制了合约在区块链存储中的占用率,合约账户按其使用的存储量所占的比例支付租金。 当合约可用余额低于某个限额时,合约账户会变成一个 “墓碑”,其存储被清空。 墓碑合约可通过提供能激活它的最少资金和被清除的数据,来重新激活合约
论文阅读-Hawk.The Blockchain Model of Cryptography and Privacy-Preserving Smart Contracts
1.引入 区块链上的可信时钟的存在对于协议中实现公平交易至关重要:恶意用户可能会过早地中止协议以避免资金支付,但是有了可信时钟,过早地中止协议会被判定为超时,这样区块链就可以将恶意用户的抵押存款...
论文阅读-TWINE An Embedded Trusted Runtime
1.Introduction TWINE(Trusted Wasm in Enclave) 使用标准Intel工具链实现 允许本地执行遗留Wasm应用程序,而无需重新编译 动态地将WASI操作...