Dylan A Blockchain Developer

关于 CoTA 你需要知道的

什么是 CoTA

CoTA 全称 Compact Token Aggregator Standard,基于 Nervos CKB Cell 模型设计的 Token 管理协议。简单来说,CoTA 就是给每个用户维护一颗专属的默克尔树(SMT),树上的叶子节点代表了 NFT 资产的数据和状态,NFT 的转让和信息更新只是叶子节点数据的更新,并不触发 Cell 所有权的变更,有点像每个用户都有一个微型的账本,记账的过程就是叶子节点更新的过程。

CoTA Cell 维护的微型账本类似于以太坊的账户模型,但是所不同的是 CoTA Cell 本身的所有权由用户的 lock 决定,关于 NFT 的任何操作都必须由用户签名解锁 lock 才能进行,而且用户可以同时给多个不同的用户转让多个不同的 NFT。

那么 CoTA Cell 是如何管理用户的 NFT 资产呢?就是上面提到的默克尔树(SMT),SMT 是常用默克尔树的变种,也称稀疏默克尔树,它的特点是可以验证某个叶子是否在树上,也可以验证某个叶子是否不在树上。用户的 NFT 信息存储在 SMT 的叶子节点中,用户 A 要给用户 B 转让 NFT 时,需要在 A 的树上删掉代表该 NFT 的叶子节点数据,同时需要在 B 的树上增加代表该 NFT 的叶子节点数据,那么这里就需要两步操作,一步操作是用户 A 先提交一个转出 NFT 的证明(Withdraw),另一步操作是用户 B 提交一个将 NFT 收下的证明(Claim)。

这里出现了一个新的名词,为什么是提交证明呢?简单来说,要验证某个叶子节点数据是否在或者不在默克尔树上,需要给出一个密码学证明,任何人都可以通过这个证明、要验证的叶子节点数据和根哈希三者来验证,这个验证可以发生在链上(智能合约),也可以发生在链下。

至此,我们可以简单描述一下一个 NFT 的转让过程了,首先发送方需要提交一个交易,该交易包含了删掉(发送给别人)某个 NFT 叶子后的默克尔树根哈希、密码学证明以及该 NFT 的相关信息(包含接收者地址),当这笔交易上链后,那么发送者就不再拥有这个 NFT 了。此时接收方只是知道了发送方给他转让了一个 NFT,但是这个 NFT 还没有在他自己的默克尔树上,那么他就还需要提交一个交易,该交易包含了拥有这个 NFT 叶子后的默克尔树根哈希、密码学证明以及该 NFT 的相关信息,同时他还需要给出发送方删掉这个 NFT 的密码学证明,也就是在这个交易中智能合约既要验证接收方有权收下一个 NFT,也要验证发送方确实给接收方转让了这个 NFT。所以整个过程是需要两步:发送方先转出,接收方再收下。

明白了 NFT 的转让过程,我们再来简单描述一下 NFT 的铸造和分发过程,首先发行方需要先定义一个系列(collection)的 NFT 的信息,比如 NFT 的名称、描述、多媒体链接地址等等,然后发行方就可以按照 id (用于表示 NFT 唯一性的 ID)递增的方式依次给其他人分发 NFT 了,分发的过程本质上就是 withdraw 的过程,只不过发行是可以凭空造出来 NFT,然后 withdraw 给别人,而 NFT 的转让则需要发送方要先拥有这个 NFT 才行。

如果每次 NFT 转让都必须先收下然后再转出,确实显得麻烦,为此 CoTA 在智能合约层面支持收下和转出在一步完成,简单说就是用户 A 给用户 B 转让了一个 NFT,也就是用户 A 提交了 withdraw 交易,如果此时用户 B 想把这个 NFT 转给用户 C,那么他可以选择先 claim 这个 NFT,然后再 withdraw 这个 NFT 给用户 C,当然他也可以直接 transfer 这个 NFT 给用户 C,这里的 transfer 其实就是把 claim 和 withdraw 合并为一个操作了。

为什么 CoTA Cell 需要注册

上面简单描述了 NFT 的转让和铸造发行过程,这其中还有一个很重要的问题没有提及,就是发送者在给接收者转让 NFT 的时候,指定的是接收者的地址,那么如果接收者用这个地址连续多次收下这个 NFT 的话,就意味着出现了双花,因此我们需要保证每一个接收方都必须有且只有一个 CoTA Cell,也就是说一个地址可以有多个 Cell,但是只能有一个 CoTA Cell,他在接收 NFT 时只能用这个 CoTA Cell,那么如何保证一个地址只能有唯一的 CoTA Cell 呢?

道理其实也很简单,就是用一个链上全局状态表记录下每个地址是否注册过 CoTA Cell,如果没有注册,那么不允许他铸造发行和转让 NFT,如果注册了,那么就不允许再次注册,而链上全局状态表也是一个默克尔树,每一个注册过的地址都会有一个叶子节点跟它一一对应,利用上面提到的存在和不存在证明,就可以保证每个地址只有一个 CoTA Cell。

为什么需要链外的 Aggregator 服务

上面提到了每一次发交易都需要提交密码学证明、根哈希以及叶子节点数据,为了尽量减少链上空间的占用,只会将最新的根哈希放置到 Live Cell 的 data 字段中,而密码学证明和叶子节点数据都在 Witness (类似比特币,Witness 最开始是放置签名数据,本身不占用交易大小,现在相当于扩展了 Witness 的用途)中,既然链上 Cell data 中只有根哈希,那么就需要一个链外的服务不停地回溯历史交易,从 Witness 中解析并保存过往所有的叶子节点数据,当需要某个叶子数据的验证证明时,就可以根据整棵树的叶子节点来生成。换句话说就是需要一个链外的服务保存最新完整的默克尔树,在每次用户发交易的时候,帮他生成密码学证明,以便合约验证交易的合法性。

Aggregator 就是这样的服务,同样的注册过程也是更新和验证默克尔树的过程,所以也需要一个 aggregator,因此就是有两个 aggregator 服务,一个服务于注册流程(registry-cota-aggregator),一个服务于 NFT 的铸造分发和转让(cota-aggregator)。

如何获取 NFT 信息

对于一个发行方来说,他可能希望能看到所有的发行记录,而对于持有人来说,他可能希望看到他所拥有的 NFT,不管这个 NFT 是否已收下(claim)等等,为了满足这部分需求,aggregator 除了帮助交易构造者生成密码学证明,还提供了更多的数据查询 RPC,例如根据地址查询所拥有的 NFT 等,更多的 RPC 详情请参考 Aggregator RPC APIs

如何快速上手 CoTA NFT 开发

合约部署信息

目前 CoTA 合约已经部署到测试网和主网,可以参考具体的部署信息,如果你对智能合约规则和原理感兴趣,也可以参考 CoTA 合约规则

Aggregator

目前支持注册和 NFT 铸造分发转让的 Aggregator 都已经发布上线,我们提供了公共的 RPC 服务供开发者调试和测试,详细的 RPC 地址可以参考 Aggregator public url,对于正式运行在主网环境的 Aggregator,我们强烈建议开发者自行部署一套,部署方式可以参考 cota-aggregatorregistry-cota-aggregator

SDK

目前我们提供 JS SDK 供开发者用来实现与链相关的所有交互,包括注册 CoTA Cell 和铸造分发转让以及更新 NFT 等操作,SDK 依赖 Aggregator 服务,在开发和测试阶段可以使用公共 RPC 服务,对于主网环境,我们强烈建议自行部署一套。对于 CoTA NFT 相关的所有操作 SDK 都提供了相应的 example 供开发者参考。

CKB 相关的知识

由于 CoTA 是基于 Nervos CKB 开发的 NFT 协议,所以对于开发者来说还需要稍微熟悉一下 CKB 的基础知识,详细参考 CKB Docs

如何在 Nervos CKB 上开发智能合约

概述

Nervos CKB 是一条基于 PoW 的 layer1 公链,其 Cell 模型是比特币 UTXO 模型的泛化,因此它的智能合约开发有别于基于以太坊账户模型的智能合约开发。以太坊的合约是链上计算,合约调用者需要给出合约方法的输入,链上会完成计算并得到输出,而 CKB 的合约是链上验证,合约调用者需要同时给出输入和输出,链上完成输入到输出的验证。

举一个简单的类比,如果你想实现在合约中实现 y = sqrt(x) 函数,对于以太坊你需要给出 x 的值,合约会计算 y 的值;而对于 CKB 来说,你需要同时给出 xy 的值,合约负责验证 xy 是否满足 y = sqrt(x)

从这个例子中可以看到,以太坊合约开发只需要关注输入和需要调用的合约函数即可,链上会完成计算和状态的更新,而 CKB 则需要在链外提前计算输入和输出,合约只需要按照相同的计算规则来验证输入和输出是否满足要求,换言之,CKB 需要同时实现链外的 Generator 和链上的 Validator,这两者的验证规则是一致的。

对于熟悉以太坊智能合约的开发者来说,CKB 的智能合约相当于是一种全新的开发模型,所有的状态改变都需要链外的 Generator 提前设定好,链上要做的只是验证状态改变是否符合规则。相比于以太坊的只需要在链上实现合约规则,CKB 需要在链外和链上同时实现两套相同的规则,这在一定程度上增加了合约开发的复杂度,不过好处是合约运行的复杂度可以大大降低,因为验证通常要比计算更简单。

还是上文提到的例子,如果你想在合约中实现 y = sqrt(x) 函数,以太坊需要在合约中实现根据输入 x 做开平方运算得到 y,而 CKB 的合约其实只需要判断 xy 是否满足 x = y^2,显然平方的计算复杂度要远小于开平方的复杂度。换言之,CKB 的合约算法可以不需要跟链外 Generator 完全保持一致,只要两者的计算是等价的即可。

Cell 和 Transaction 的数据结构

因为 CKB 的合约本质上是通过交易来改变 Cell 的状态,因此我们强烈建议先熟悉 Cell 和 Transaction 的数据结构,否则会影响后续合约的理解,详情可以参考Transaction Structure

// Cell
{
  capacity: uint64,
  lock: Script,
  type: Script,
}

// Script
{
  code_hash: H256,
  args: Bytes,
  hash_type: String    // type or data
}

inputsoutputsoutputs_data 代表了 Cell 在一笔交易前后的状态变化,Cell 包含了 lock script(必需)和 type script(非必需),CKB VM 会执行 inputs 中的所有的 lock scripts,以及 inputsoutputs 中的所有 type scriptslock scripttype script 包含了对 Cell 状态约束的合约规则。

关于 Script 中 code_hash、args 以及 hash_type 可以参考Code Locating,请务必先阅读,否则会影响后续合约的理解。

VM Syscall

由于我们需要在合约中判断 Cell 在一笔交易中前后的状态变化是否符合一定的规则,那么首先我们就需要在合约中可以获取到 Cell 和 Transaction 中的数据,CKB VM 提供了 syscall 帮助我们在合约中访问 Cell 和 Transaction 中的数据:

- ckb_load_tx_hash
- ckb_load_transaction
- ckb_load_script_hash
- ckb_load_script
- ckb_load_cell
- ckb_load_cell_by_field
- ckb_load_cell_data
- ckb_load_cell_data_as_code
- ckb_load_input
- ckb_load_input_by_field
- ckb_load_header
- ckb_load_header_by_field
- ckb_load_witness

可以看到 VM Syscall 提供了大量获取 Cell 和 Transaction 数据的方法,这些方法可以在 C 语言代码中直接调用,具体的参数和调用细节可以参考VM Syscall

#include "ckb_syscalls.h"

// We are limiting the script size loaded to be 32KB at most.
// This should be more than enough.
// We are also using blake2b with 256-bit hash here,
// which is the same as CKB.
#define BLAKE2B_BLOCK_SIZE 32
#define SCRIPT_SIZE 32768

#define ERROR_SYSCALL -3
#define ERROR_SCRIPT_TOO_LONG -21

int main() {
  // First, let's load current running script,
  // so we can extract owner lock script hash from script args.

  unsigned char script[SCRIPT_SIZE];
  uint64_t len = SCRIPT_SIZE;

  int ret = ckb_load_script(script, &len, 0);
  if (ret != CKB_SUCCESS) {
    return ERROR_SYSCALL;
  }
  if (len > SCRIPT_SIZE) {
    return ERROR_SCRIPT_TOO_LONG;
  }

  return CKB_SUCCESS;
}

上面的合约例子展示了如何读取当前 script 数据,以及判断 script 数据是否符合长度要求,CKB 的系统合约都是 C 语言实现的,详情可以参考 ckb-system-scripts 以及 ckb-miscellaneous-scripts

Capsule

为了降低合约开发、调试、测试和部署的门槛,Nervos CKB 推出了基于 Rust 语言的智能合约开发框架 Capsule,旨在提供开箱即用的解决方案,以帮助开发者快速而轻松地完成常见的开发任务。

USAGE:
capsule [SUBCOMMAND]

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

SUBCOMMANDS:
    check           Check environment and dependencies
    new             Create a new project
    new-contract    Create a new contract
    build           Build contracts
    run             Run command in contract build image
    test            Run tests
    deploy          Deploy contracts, edit deployment.toml to custodian deployment recipe.
    debugger        CKB debugger
    help            Prints this message or the help of the given subcommand(s)

通过 Capsule 命令行可以完成智能合约的创建、编译、测试、调试和部署,关于 Capsule 的详细使用说明可以参考 Write a SUDT script by Capsule

为了能让 Rust 开发者在 Capsule 框架中调用 VM Syscall 方法,Nervos CKB 提供了 ckb-std 以及相关使用文档,开发者可以在合约中引入 ckb-std,从而使用 high_level 模块下的方法完成对 CKB Cell 和 Transaction 数据的调用。

// Module ckb-std::high_level
find_cell_by_data_hash
load_cell
load_cell_capacity
load_cell_data
load_cell_data_hash
load_cell_lock
load_cell_lock_hash
load_cell_occupied_capacity
load_cell_type
load_cell_type_hash
load_header
load_header_epoch_length
load_header_epoch_number
load_header_epoch_start_block_number
load_input
load_input_out_point
load_input_since
load_script
load_script_hash
load_transaction
load_tx_hash
load_witness_args

以下是一些常见的使用 high_level 方法的例子:

// Call current script and check script args length
let script = load_script()?;
let args: Bytes = script.args().unpack();
if args.len() != 20 {
    return Err(Error::InvalidArgument);
}

// Call the input of index 0
let cell_input = load_cell(0, Source::Input)?

// Call the output of index 0
let cell_output = load_cell(0, Source::Output)?

// Filter inputs whose lock script hash is equal to the given
// lock hash and calculate the sum of inputs' capacity
let cell_inputs = QueryIter::new(load_cell, Source::Input)
      .position(|cell| &hash::blake2b(cell.lock().as_slice())
      == lock_hash)

let inputs_sum_capacity = cell_inputs.into_iter()
      .fold(0, |sum, c| sum + c.capacity().unpack())

// Check if there is an output with lock script hash equal to
// the given lock hash
let has_output = QueryIter::new(load_cell, Source::Output)
      .any(|cell| &hash::blake2b(cell.lock().as_slice())
      == lock_hash)

// Check whether the witness args' lock is none of witness
// whose index in witnesses is 0
match load_witness_args(0, Source::Input) {
  Ok(witness_args) => {
    if witness_args.lock().to_opt().is_none() {
      Err(Error::WitnessSignatureWrong)
    } else {
      Ok(())
    }
  },
  Err(_) => Err(Error::WitnessSignatureWrong)
}

如果需要在合约中验证签名,ckb-dynamic-loading-secp256k1 给出了如何通过 Rust 代码调用系统 Secp256k1 的 C 代码,ckb-dynamic-loading-rsa 给出了如何通过 Rust 代码调用 RSA 签名算法的 C 代码。

更多关于 Capsule 开发智能合约的例子可以参考以下项目:

调试

在合约开发过程中遇到不符合预期的错误是很常见的,比较常见的调试方式是在合约中打印日志,ckb-std 提供了 debug! ,其用法类似于 Rust 语言中的 print! ,而在合约的 tests 中,可以直接使用 print!println! 来打印。

测试

对于 CKB 智能合约来说,Capsule 可以帮助开发者实现合约的本地测试,而无需部署到 Nervos CKB 开发链或者测试链,可以极大得降低合约调试难度,提升合约测试效率。关于如何在 Capsule 中实现合约的测试用例,可以参考 Write a SUDT script by Capsule # Test

部署

对于 CKB 智能合约来说,除了常规的二进制代码直接部署,用二进制代码的 hash 作为 code hash 的方式,还有 Type ID 部署方式,code hash 取自 type script hash

TYPE ID 以及 dep_group 等部署方式可以在 deployment.toml 文件中配置,最终的部署可以参考 Write a SUDT script by Capsule # Deployment

常见错误

合约在开发过程中,难免会遇到各种各样的错误,如何快速定位问题并修复就显得很重要了,如果你的合约中用到了 CKB 的系统合约,例如 secp256k1_blake160_sighash_allsecp256k1_blake160_multisig_all 或者 Nervos DAO,那么你可以参考系统合约错误码以及相应的错误解释来快速定位问题。

比较常见的错误有:

  • 1: 数组越界,检查是否访问了超过数组长度的索引
  • 2: 缺少某项数据,例如某个 Cell 需要有 type script,但是在拼装交易的时候漏掉了
  • -1: 参数长度错误,有可能是 script args 或者 signature 长度不对
  • -2: 编码异常,检查 Cell 和 Transaction 的数据是否符合 molecule 要求,比如多了或者少了 0xhex string 长度为奇数等等
  • -101 ~ -103:Secp256k1 验签失败,检查合约和 Transaction Witnesses 以及 Script 参数是否正确

  • InvalidCodeHash: Script Code Hash 无效,检查 code hash 是否正确,以及 cell deps 是否包含了该 code hash 对应的 cell dep
  • ExceededMaximumCycles: 合约消耗的 Cycles 数量已经超过了最大上限
  • CapacityOverflow: Capacity 溢出,请检查 outputs 的 capacity 总和是否大于 inputs 的 capacity 总和
  • InsufficientCellCapacity: Cell 数据实际占用的字节数大于当前 Cell 的 capacity(capacity 代表 Cell 能承载的数据的字节数)
  • Immature: 由于 input since 不为零,当前 input 还不能被消费

当然还有很多系统合约错误,上面只是列举了比较常见的错误类型,详情可以参考 Error CodesVerification ErrorScript Error

除了系统合约的错误码,对于特定的业务合约也会有自己的错误码,这个时候就需要去看定义在业务合约中的错误码,定位可能出错的地方,例如 ckb-cheque-script error code

对于合约可能出错的地方都应该抛出相应的错误码,这也不仅有利于合约本身的调试,也可以帮助链外 Generator 在拼装交易的时候更容易定位问题。