在本篇文章中,让我们和 CKB 开发者 Ian 一起深入探究 CKB 基本的数据结构——交易。
这篇文章分成两个部分。第一个部分包含了核心的交易特征,而第二部分介绍一些扩展内容。在撰写本文时,对应的 CKB 版本是 v0.25.0,在未来的版本中交易结构还可能有所变动。您可以点击阅读原文,查看最新版本。
上图是关于交易结构的概览。有别于逐字逐句的解释各个名词,我将会介绍 CKB 转账能够提供的各种特殊结构,以及这些名词在其中的具体意思。
Part I:核心特征
价值储存
CKB 采用的是 UTXO 模型。一笔交易销毁了一些在先前交易下创建的输出(作为输入),并且创建一些新的输出,我们在 CKB 中将此交易输出称做一个 Cell。因此在这里的 Cell 和交易输出是可以替换的。
下图显示了在此层中会出现的专有名词。
此交易销毁了 inputs 中的 Cell,同时创建了在 outputs 中的 Cell。
CKB 主链将交易打包成块。我们可以在区块中利用从零(也就是创世区块)开始递增的非负整数(编号),作为区块编号来关联链上的区块。在区块中的交易也是按照顺序排列的。我们可以说编号较小的区块是较早(old)的区块,如果一个交易在较早的区块上,或者它所在的区块的位置早于其它区块,那么它也会是比较早的交易。在下面的示例中,区块 i 比区块 i+1 早。交易 tx1 要比 tx2 早,也比 tx3 早。
在所有先前的交易中,一个可用(Live)的 Cell 会以输出而非输入的形式出现。一个被销毁(Dead)的 Cell 代表它是以输入的形式在其它较早的交易中被使用过。一个交易只能以可用的 Cell 作为输入。
我们可以从除了 witnesses 之外的所有交易字段计算交易的哈希。关于如何计算交易哈希的更多信息,可以参阅附录 A。
交易哈希是独一无二的。因为一个 Cell 总是被一个交易创建出来,而每个新的 Cell 在交易输出的数组中都有他自己的位置,所以我们可以通过交易哈希以及输出索引去指向一个 Cell。OutPoint 结构是一种引用类型。交易在输入时会使用 OutPoint 来指向先前被创建的 Cell,而非嵌入其中。
Cell 将 CKB 代币存储在字段 capacity 中。一个交易不能够凭空铸造 capacity,所以交易必将符合以下规则:
sum(cell’s capacity for each cell in inputs)
≥ sum(cell’s capacity for each cell in outputs)
在输入中每个 Cell 容量的总和要大于等于在输出中每个 Cell 容量的总和。
矿工可以收取这两者之间的价差做为手续费。
fee = sum(cell’s capacity for each cell in inputs)- sum(cell’s capacity for each cell in outputs)
如果你熟悉比特币,那么就会发现在价值储存层都是相似的,但是比特币缺乏锁定脚本来保护交易输出的所有权。CKB 正好有这个特征,但是在我们探讨这个话题之前,让我们先来谈谈 Cell Data 和 Code Locating 层吧,这是任何 CKB 中脚本特征的依据。
Cell Data
除了能够存储价值通证以外,CKB Cell 还能储存任意数据。
字段 outputs_data 是输出的并行数组。在 outputs 中第 i 个 Cell 的数据对应的是 outputs_data 中的第 i 项。
Cell 中的 capacity 不只代表通证的数量,也代表能够存储数据的限制。这也是它如此命名的原因,因为它代表 Cell 的存储容量。
capacity 不仅能用于存储数据,它还需要涵盖 Cell 中的所有字段,包括 data、 lock、type 以及 capacity 本身。
计算占用容量的规范请参考:
https://github.com/nervosnetwork/ckb/wiki/Occupied-Capacity
交易势必会创建一个占用容量小于(输入) Cell 容量的输出 Cell。
occupied(cell) ≤ cell’s capacity
代码定位
Cell 中有两个字段的类型是 Script。CKB-VM 会运行所有输入 Cell 中的 lock 脚本,还会运行所有输入和输出 Cell 中的 type 脚本。
我们区分了代码和脚本这两种术语:
· 脚本具有脚本结构
· 代码是 RISC-V (可运行的)二进制
· 一个代码 Cell 是其数据为代码的 Cell
脚本并没有直接包含代码。看看下面的脚本结构。现在我们可以忽略哈希类型的 Type 以及 args 字段。
当 CKB-VM 需要运行一个脚本时,它必须要先找到它的代码。字段 code_hash 和 hash_type 就是用来查看代码的。
在 CKB 中,脚本代码会被编译成 RISC-V 二进制文件。这个二进制文件是以数据的形式存储在 Cell 中的。当 hash_type 是数据时,脚本会被定位在一个数据哈希和脚本的 code_hash 相等的 Cell 中。Cell 数据哈希,如其名所示,是从 Cell 的数据中算出来的(详见附录 A)。在交易中的范围是有限制的,脚本只能从 cell_deps 中找到一个匹配的 Cell。
下图将解释 CKB 如何找到相应的脚本代码。
如果你想使用 CKB 中的脚本,那么应该遵循代码定位的规则:
· 把你的代码编译成 RISC-V 二进制文件。你可以在建构系统 Cell 代码的仓库中找到一些案例:https://github.com/nervosnetwork/ckb-system-scripts
· 通过一笔交易,创建一个将二进制文件作为数据的 cell,并将交易发到链上。
· 建构一个脚本结构,其 hash_type 是「数据」,code_hash 只是构建二进制文件的哈希
· 使用脚本作为 Cell 中的一种形态或锁定脚本。
· 如果脚本必须在交易中运行,请包含指向 cell_deps 的代码 Cell。
在 cell_deps 的 Cell 必定是可用的,就像是 inputs 一样。但有别于 inputs,一个只在 cell_deps 中被使用的 Cell 不会被认为是被销毁的。
下面两个章节我们将讨论脚本如何在交易中用于锁定 Cell,以及如何建立 Cell 上的合约。
锁定脚本
每个 Cell 都有一个锁定脚本。当交易中的 Cell 被以输入的形式使用时,锁定脚本必须执行。当脚本只出现在输出时, 则不需要显示在 cell_deps 中相应的代码中。交易只有在所有的输入中锁定脚本都正常(执行并)退出时才有效。因为脚本在输入上运行,所以它可以扮演锁的角色来控制谁可以解锁或者销毁 Cell,以及花费储存在 Cell 中的容量。
以下是一个总是可以正常(执行并)退出的锁脚本的代码示例。如果使用这段代码作为锁脚本,那么任何人都可以销毁这个 Cell。
int main(int argc, char *argv[]) {
return 0;
}
最主流的锁定数字资产的方式是用非对称加密创建的数字签名。
这个签名演算法有两个要求:
· Cell 必须要包含公钥的信息,所以只有真正的私钥可以创建有效的签名;
· 交易必须包含签名,而且通常以整个交易作为消息去签名。
在 CKB 中,公钥指纹可以在脚本结构的 args 字段中被存储,同时这个签名可以在交易的 witnesses 字段中被存储。我使用「可以」是因为这只是一个我推荐的方式,并且只用在默认的 secp256k1 锁定脚本中:
https://github.com/nervosnetwork/ckb-system-scripts/blob/master/c/secp256k1_blake160_sighash_all.c
脚本代码可以读取交易的任何一部分。所以锁定脚本可以选择不同的协定,例如,储存公钥的信息在 Cell 数据中。然而,如果所有锁定脚本都跟随推荐的协定,他就可以简化创建交易的应用程序,像是钱包。
让我们看一下脚本代码是如何被定位和载入的,以及代码如何访问输入、脚本参数(script args)和 witnesses。
首先,请注意 CKB 并不会在逐个输入之间运行锁定脚本。首先它会先按锁定脚本进行分组,并且只运行一次相同的锁定脚本。CKB 会按照这三个步骤运行:脚本分组(script grouping),代码定位(code locating)以及运行。
上图展示了第一步中的两个步骤:
1. 首先,CKB 会借由锁定脚本去进行分组。在示例的交易里有两个不同的锁定脚本在输入中被使用。虽然它们都定位在相同的代码中,但它们有不同的 args。我们来看一下 g1。这里有两个索引为 0 和 2 的输入。脚本和输入索引会在步骤三后被使用。
2. 然后 CKB 会从 cell deps 上查找代码。这将 Cell 解析成带有数据的哈希 Hs,并且将会用此数据作为代码。
目前 CKB 可以载入脚本代码的二进制文件并通过 entry 函数开始运行代码。
脚本可以通过 syscall 的 ckb_load_script 进行自读取:
ckb_load_script(addr, len, offset)
许多 CKB 的 syscall 都被设计为从交易中读取数据。这些 syscall 用有一个指明哪里可以读取数据的参数。例如,载入第一个 witness:
ckb_load_witness(addr, len, offset, 0, CKB_SOURCE_INPUT);
第一个参数控制哪里可以存储读取的数据,以及有多少 byte 已被读取。在接下来的章节中我们先忽略它。
第五个参数是数据源。CKB_SOURCE_INPUT 代表从交易输入中读取,第四个参数 0 则是输入数组的索引。CKB_SOURCE_INPUT 也用于读取 witnesses。
记住当我们通过锁脚本将输入进行分组时,我们已经保存了输入的索引。这个信息用于为分组创建虚拟的 witness 和输入数组。这段代码可以通过一个特殊的数据源 CKB_SOURCE_GROUP_INPUT,利用虚拟数组中的索引去读取输入或 witness。读取 witness 时,通过 CKB_SOURCE_GROUP_INPUT 只读取拥有相同位置并具有特定输入的 witness。
所有读取和输入相关数据的 syscall,都可以使用 CKB_SOURCE_GROUP_INPUT 以及在虚拟输入数组中的索引,例如 ckb_load_cell_* 的 syscall 系列。
类型脚本
类型脚本和锁定脚本很相似,但有两点不同:
· 类型脚本是可选的;
· 在任一交易中,CKB 必须在输入和输出端都运行类型脚本。
虽然我们只能在 Cell 中维持一种脚本,但我们不会想要在一个单一的脚本中扰乱(脚本)不同的职责。
锁定脚本只为输出执行,所以他的首要任务是保护 Cell。只有所有者可以以输入的形式使用 Cell,以及花费储存于其中的通证。
类型脚本的目的是在 Cell 上建立合约。当你得到一个特殊类型的合约时,你可以确定 Cell 已经在特定代码中通过验证。同时这个代码也会在 Cell 被销毁时被执行。类型脚本的典型情况是用户自定义的 Token,这种类型脚本必须在输出上运行,所以通证的发行必须被授权。
在输入上运行类型脚本对合约而言非常重要;例如一个让用户可以在线下抵押 CKB 来租用资产的合约,如果这个类型脚本不在输入上运行,用户可以在没有权限的情况下从合约中取回 CKB,只需销毁这个 Cell 并将容量转移到一个没有类型脚本的新 Cell 上即可。
这个运行类型脚本的步骤和锁定脚本也很相似,除了:
1. 没有类型脚本的 Cell 会被忽略
2. 脚本群组包含输入与输出
像 CKB_SOURCE_GROUP_INPUT,有一个特殊的数据源 CKB_SOURCE_GROUP_OUTPUT 可以将索引用于脚本组的虚拟输出数组中。
PART I 交易结构概述
Part II:扩展
在第一部分,我介绍了交易提供的核心特征。在这个部分,我将介绍一些 CKB 不需要他们也能运行的延伸套件的特征,但这些套件可以使 Cell 模型变的更好。
下图是在这个部分会出现的新字段(标有黄色底)的总览。
Dep Group
Dep Group 是一个捆绑许多 Cell 作为其成员的 Cell。当一个 dep group 的 Cell 在 cell_deps 中被使用时,它的效果和添加全部的成员到 cell_deps 中是一样的。
Dep Group 在 Cell 数据中存储了一系列的 OutPoint 清单。每一个 OutPoint 都指向其中一个群体的成员。
CellDep 结构中有个叫 dep_type 的字段,可以用于区分直接提供代码的普通 Cell,和在 cell_deps 中扩展至其成员的 dep group。
Dep group 会在定位和运行节点之前被扩展,只有被扩展的 cell_deps 才是可见的。
在 v0.19.0 版中,锁定脚本 secp256k1 被分成 code cell 和 data cell。code cell 通过 cell_deps 载入 data cell。所以如果一个交易要解锁一个被 secp256k1 锁定的 Cell,那么他一定要在 cell_deps 加上两个 Cell。在 dep group 中,交易就只需要 dep group 即可。
我们分离 secp256k1 cell 有两个原因。
· code cell 很小,这让我们可以在区块大小限制很低的时候就更新它。
· data cell 可以被共享。例如,我们已经安装了另一个使用 ripemd160 的锁定脚本来验证公钥的哈希值。这个脚本就重用了 data cell。
可升级脚本
在第一部分的锁定脚本中,我描述了一个脚本如何通过 Cell 的数据哈希来定位它的代码。一旦一个 Cell 被创建,那么相关联的脚本代码就不会被改变,因为要找到一个有相同的哈希的另一段代码是不可能的。
脚本有另一个 hash_type 的选项,Type。
当脚本使用了 hash type 的 Type,它会匹配相等于 code_hash 的类型脚本哈希的 Cell。类型脚本的哈希是从 Cell 的 type 字段中计算出来的(详见附录 A)。
现在,如果 Cell 用一个通过类型脚本哈希来定位代码的脚本,并通过创建一个具有相同类型脚本的新 Cell,那么我们就可以升级代码了。新的 Cell 已经有更新的脚本,在 dep_cells 中增加了新 Cell 的交易将会使用这个新的版本。
然而,这只解决了一个问题。如果一个作恶者创建一个拥有同种类型脚本的 Cell,但使用伪造的代码作为数据,那么这还是不安全的。作恶者可以通过使用假的 Cell 作为 dep 来绕开脚本验证。下一章将描述解决第二个问题的脚本。
因为被类型脚本哈希引用的代码是可以被改变的,所以你必须信任脚本作者使用的这种类型脚本。虽然使用哪个版本取决于哪一个 Cell 在 dep_cells 的交易中被添加。用户总是可以在签署交易之前检查代码。但是,如果脚本用于解锁 Cell,那么签名检查甚至是可以被略过的。
Type ID
我们选择 Cell 类型脚本哈希的理由是用来支持可更新的脚本。如果作恶者想要创建具有特化的形态脚本的 Cell,那么交易必须被类型脚本的代码验证。
Type ID 就是这种类型的类型脚本。如其名所示,他确保了类型脚本的独特性。
这个特征包括了多种类型脚本,所以我会用不同专有名词去区分他们:
· Type ID 的代码 Cell 是一个存储代码来验证 type id 是否是独一无二的 Cell。
· Type ID 的代码一样有类型脚本。我们现在不会在意实际的内容,让我们假设类型脚本的哈希是 T1
· Type ID 是一个 hash_type 是 Type,且 code_hash 是 T1 的类型脚本。
从 Part I 的类型脚本中我们知道,类型脚本会先将输入与输出聚集。换句话说,如果一个类型脚本是 type ID,那么在同个群组的输入与输出都有同样的 type ID。
Type ID 的代码在验证的就是在任何代码中,至少都会有一组输出和一组输入。根据输入与输出的数量,一个交易允许有多种 type id 群组,type id 群组被分成以下三种不同的类型:
Type ID Creation Group 只有一个输出
Type ID Deletion Group 只有一个输入
Type ID Transfer Group 有一个输入和一个输出
上图的交易中有三种交易 ID 群组
G1 是将 type id 从 cell1 转移到 cell4 的 Type ID Transfer Group
G2 是依据 cell2 去删除 type id 的 Type ID Deletion Group
G3 是为 cell3 创建新的 type id 的 Type ID Creation Group
在 Type ID Creation Group 中,在 args 中的唯一参数,是在群组中这个交易的第一个 CellInput 结构的哈希,还有 Cell 的输出索引。例如,在群组 g3 中,id3 是一个在 tx.inputs[0] 和 0(cell3 在 tx.outputs 的索引)上的哈希。
这里有两种可以透过特定的 type id 来创建新 Cell 的方法:
1. 在 tx.inputs[0] 的哈希上创建一个交易以及任意等于特定值的索引。因为一个 Cell 只能被以输出的形式在链上被使用一次,所以 tx.inputs[0]在每个交易中必须是不同的。这个问题和找到一个哈希碰撞的值是一样的,可能性几乎可以忽略不计。
2. 在同一个交易中销毁较早的 Cell
我们假设方法 2 是唯一一个创建相等于既有 type id 的新 Cell 的方法。这个方法需要原有的所有者的授权。
Type ID 的代码只能通过 CKB-VM 的代码实行,但我们会选择在 CKB 的节点上以一个特殊的系统脚本来实行它,因为如果我们以后想升级 Type ID 的代码,就必须要通过一个递归依赖的类型脚本代码,来将自己作为类型脚本。
Type ID 的代码 Cell 使用了一个特殊的类型脚本哈希,也就是文本 TYPE_ID。
0x00000000000000000000000000000000000000000000000000545950455f4944
Header Deps
Header Deps 可以让脚本来读取区块头。这个功能有一定的限制以确保交易被确定。
我们只会在所有交易中的脚本已经有确定的结果时,才会说一笔交易已经确定了。
Header Deps 允许脚本去读取其哈希已经列在 header_deps 中的区块头。还有另一个先决条件,是交易只能在所有列在 header_deps 的区块都已经在链上时(叔块除外),才可以被添加到链上。
有两种方式可以利用 ckb_load_header 系统调用来载入脚本中的区块头:
· 通过 header deps 的索引
· 通过输入或一个 cell dep。如果区块有被列在 header_deps 的话,系统调用会返回 Cell 被创建出的那个区块。
第二种载入区块头的方法有另一个好处是,脚本会知道 Cell 位于被载入的区块中。DAO 取出交易借此来获取存储容量的区块号。
下面是上图中 loading header 的一些例子。
// Load the first block in header_deps, the block Hi
load_header(…, 0, CKB_SOURCE_HEADER_DEP);
// Load the second block in header_deps, the block Hj
load_header(…, 1, CKB_SOURCE_HEADER_DEP);
// Load the block in which the first input is created, the block Hi
load_header(…, 0, CKB_SOURCE_INPUT);
// Load the block in which the second input is created.
// Since the block creating cell2 is not in header_deps, this call loads nothing.
load_header(…, 1, CKB_SOURCE_INPUT);
其他字段
字段 since 可以避免交易在特定时间前被挖出。详见 since RFC。
https://github.com/nervosnetwork/rfcs/blob/master/rfcs/0017-tx-valid-since/0017-tx-valid-since.md
字段 version 是预留在未来使用的。在当前版本中,它必须等于0。
特例
在此系统中有两个特殊的交易。
第一个是 cellbase,这是每个区块中的第一个交易。cellbase 中只有一个仿真的输入。在这个仿真的交易中,previous_outpoint 不会关联人其他的 Cell,而是会设置一个特殊值。since 必须设置为区块编号。
cellbase 的输出是链上给早区块的奖励和交易费。
cellbase 是特殊的,因为他的输出容量并不来自于输出。
另一个特殊的交易是 DAO 的取款交易。它也有部分的输出容量并非来自于输入。这部分是在 DAO 中锁定 Cell 所获得的收益。CKB 会通过检查是否有使用 DAO 作为类型脚本的输入来识别 DAO 的取款交易。
附录A:计算多种哈希
加密原语
· blake256
CKB 使用 blake2b 作为默认的哈希演算法。我们用 blake256 来表示一个函数,用个人的「ckb-default-hash」来获取 blake2b 哈希的前 256 位。
交易哈希
交易哈希是 blake256(tx_hash_digest(tx)) ,其中 tx_hash_digest 是将交易中的所有字段(不包括 witnesses )序列化为二进制的方法。序列化规范还没有进行最终的确定,现在,您可以通过 RPC _compute_transaction_hash 获取交易哈希。
Cell 数据哈希
cell 的数据哈希只是 blake256(data)。
脚本哈希
脚本哈希是 blake256(serialize(script)),serialize 将脚本结构转换成二进制块。序列化规范还没有进行最终的确定,现在你可以通过 RPC _compute_script_hash 来获取脚本哈希。
作者:Ian,Nervos 工程师,专注于系统设计和客户端实现,前 Hooya Game CTO,前 Groupon 软件工程师。
原文链接:
https://github.com/nervosnetwork/rfcs/blob/transaction-structure/rfcs/0022-transaction-structure/0022-transaction-structure.md