在本篇文档里,我们会使用 mruby 来作为编程语言演示如何在 CKB 实现一个最基础 UDT (User Defined Token) 合约
首先我们需要启动一个本地的开发链来方便调试
我们先用 ckb-cli 命令生成一个新账户用于挖矿,运行 ckb-cli,然后输入
account new
会得到如下的类似结果,其中包含 lock_arg
address:
mainnet: ckb1qyqtyqhu65hhuknlmj5ky5jydc0gcvq89pks8url9f
testnet: ckt1qyqtyqhu65hhuknlmj5ky5jydc0gcvq89pks6eaqf4
lock_arg: 0xb202fcd52f7e5a7fdca96252446e1e8c3007286d
将前面得到的 lock_arg 作为参数来初始化
./ckb init -c dev --ba-arg 0xb202fcd52f7e5a7fdca96252446e1e8c3007286d
dev chain 挖矿用的是固定间隔出块的 Dummy 模式,默认出块间隔是 5 秒,为了方便测试和调试,我们希望出块能够快一些,可以修改 ckb-miner.toml
里面的配置,将默认值 5000 (毫秒) 改成 1000 或者其他更小的值
[[miner.workers]]
worker_type = "Dummy"
delay_type = "Constant"
value = 5000
因为难度调整的关系,为了避免出块间隔时间改太小之后可能出现的难度溢出,还需要修改一下 specs/dev.toml
的配置,将这个被注释掉的参数取消注释
# permanent_difficulty_in_dummy = true
然后启动 ckb 节点
./ckb run
再开一个窗口执行本地的挖矿
./ckb miner
等几秒钟,如果在挖矿窗口看到 Total nonces found: 1
这样的输出,就代表配置都正常了
因为 mruby 是一个解释型语言,我们还需要编译一个 mruby 的解释器部署在 CKB 上
相关的代码仓库在 https://github.com/xxuejie/ckb-mruby ,克隆之后用 docker 进行编译
git clone --recursive https://github.com/xxuejie/ckb-mruby
cd ckb-mruby
sudo docker run --rm -it -v `pwd`:/code nervos/ckb-riscv-gnu-toolchain:bionic-20191012 bash
在下面的命令编译完成之后,你会在 build 目录下面找到编译好的解析器 entry
文件
apt-get update
apt-get install -y ruby
cd /code
make
exit
因为部署 mruby 解释器需要一些 CKB 容量,我们可以直接用 dev chain 在创世块里面预分配的账户容量 https://github.com/nervosnetwork/ckb/blob/develop/resource/specs/dev.toml#L70
我们需要将预分配的账户私钥写入到一个文件
echo d00c06bfd800d27397002dca6fb0993d5ba6399b4238b2f29ee9deb97593d2bc > /tmp/issued_cell_private_key
然后将这个私钥通过 ckb-cli 导入
account import --privkey-path /tmp/issued_cell_private_key
导入成功后,你将会看到这样的账户信息
address:
mainnet: ckb1qyqvsv5240xeh85wvnau2eky8pwrhh4jr8ts6f6daz
testnet: ckt1qyqvsv5240xeh85wvnau2eky8pwrhh4jr8ts8vyj37
lock_arg: 0xc8328aabcd9b9e8e64fbc566c4385c3bdeb219d7
查看一下编译好的 mruby 解析器 entry
文件大小,是 460600 Byte,我们用 ckb-cli 来执行操作,同时将这个解析器的内容作为 data 保存在 tx output,下面的命令多加 100 Byte 是因为 CKB 交易自己本身还需要需要一些容量
wallet transfer --to-address ckt1qyqvsv5240xeh85wvnau2eky8pwrhh4jr8ts8vyj37 --from-account 0xc8328aabcd9b9e8e64fbc566c4385c3bdeb219d7 --to-data-path /tmp/ckb-mruby/build/entry --capacity 460700 --tx-fee 0.01
这样就部署好了,请记录下这个命令产生的 tx hash,后面我们会一直用到这个值,这里假设生成的 tx hash 是0xdf4b4ee062684c212c25eeb7f0110bcaf3aa9e547cfb6913d0c928f32acb5e86
在开始写 UDT 之前,我们先写一个最简单的 mruby 合约来测试看看之前部署是否一切正常,这个合约会校验第一个 output data 里面的内容是不是保存了 'Hello World' 字符串。CKB 的 output 数据结构有两个字段 lock
和 type
, 他们都对应到一个 Script
结构 ( https://github.com/nervosnetwork/rfcs/blob/master/rfcs/0019-data-structures/0019-data-structures.md#Script ) ,lock 字段通常用来做交易的所有权校验,type 字段通常用来校验交易 cell 转换前后数据是否满足规则 ( input / output ),从而可以实现任意预定义规则的交易,这篇文档里面演示的合约就是通过 type 来实现,而 lock 字段还是使用 CKB 默认提供的 secp256k1 签名校验
将下面的合约写入一个文件 /tmp/hello_world.rb
cell_data = CKB::CellData::new(CKB::Source::OUTPUT, 0)
raise 'boom' if cell_data.readall != 'Hello World'
我们将会用 ruby sdk 来部署合约 ( https://github.com/nervosnetwork/ckb-sdk-ruby ) 克隆这个项目之后,使用 ruby console
git clone https://github.com/nervosnetwork/ckb-sdk-ruby
cd ckb-sdk-ruby
bundle exec bin/console
在 ruby console 里面执行如下的代码
mruby_tx_hash = '0xdf4b4ee062684c212c25eeb7f0110bcaf3aa9e547cfb6913d0c928f32acb5e86'
mruby_dep = CKB::Types::CellDep.new(out_point: CKB::Types::OutPoint.new(tx_hash: mruby_tx_hash, index: 0))
mruby_data_hash = CKB::Blake2b.hexdigest(File.read('/tmp/ckb-mruby/build/entry'))
hello_world = CKB::Types::Script.new(code_hash: mruby_data_hash, args: CKB::Utils.bin_to_hex(File.read("/tmp/hello_world.rb")))
api = CKB::API.new
wallet = CKB::Wallet.from_hex(api, '0xd00c06bfd800d27397002dca6fb0993d5ba6399b4238b2f29ee9deb97593d2bc')
data = CKB::Utils.bin_to_hex('Hello World')
tx = wallet.generate_tx(wallet.address, CKB::Utils.byte_to_shannon(300), data, fee: 5000)
tx.cell_deps.push(mruby_dep)
tx.outputs[0].type = hello_world
api.send_transaction(tx.sign(wallet.key))
如果之前部署一切正常的话,将会得到一个 tx hash,你也可以尝试将 output data 改成其他内容,这样提交交易就无法通过校验了,会得到如下的错误
CKB::RPCError: jsonrpc error: {:code=>-3, :message=>"Script: ValidationFailure(-2)"}
接下来我们来实现一个实现最基本功能的 UDT 合约,它能够发行,转账,以及销毁 UDT
首先需要限制只有某个私钥才能增发和销毁这个 UDT,因为 CKB 默认的 lock script 已经提供了签名覆盖,所以可以通过判断 lock script hash 来实现这个限制
先通过 ruby console 获取私钥对应的 lock script hash
api = CKB::API.new
wallet = CKB::Wallet.from_hex(api, '0xd00c06bfd800d27397002dca6fb0993d5ba6399b4238b2f29ee9deb97593d2bc')
wallet.lock_hash
会得到 0x32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947
,我们将这个值的对比判断写成一个合约方法
def is_issuer(lock_hash)
index = 0
while true
begin
input_lock_hash = CKB::CellField.new(CKB::Source::INPUT, index, CKB::CellField::LOCK_HASH)
if input_lock_hash.readall != lock_hash
return false
else
index += 1
end
rescue CKB::IndexOutOfBound
return true
end
end
end
类似前面的 Hello World 例子,我们可以将 UDT 的数量存储在 output data,在转账的时候检查所有的 input 和 output UDT 数量相等即可,再写一个合约方法:
def udt_amount(field)
index = 0
total = 0
while true
begin
cell_data = CKB::CellData::new(field, index)
total += cell_data.readall.to_i
index += 1
rescue CKB::IndexOutOfBound
return total
end
end
end
然后将这两个条件判断组合在一起,就完成了整个 UDT 合约
if !is_issuer(['32e555f3ff8e135cece1351a6a2971518392c1e30375c1e006ad0ce8eac07947'].pack('H*')) && udt_amount(CKB::Source::GROUP_INPUT) != udt_amount(CKB::Source::GROUP_OUTPUT)
raise 'invalid udt tx'
end
将上面这3段代码写入到一个合约文件 /tmp/udt.rb
,在 ruby console 里面执行如下的代码来发行 10000 UDT 到一个随机私钥地址
mruby_tx_hash = '0xdf4b4ee062684c212c25eeb7f0110bcaf3aa9e547cfb6913d0c928f32acb5e86'
mruby_dep = CKB::Types::CellDep.new(out_point: CKB::Types::OutPoint.new(tx_hash: mruby_tx_hash, index: 0))
mruby_data_hash = CKB::Blake2b.hexdigest(File.read('/tmp/ckb-mruby/build/entry'))
udt = CKB::Types::Script.new(code_hash: mruby_data_hash, args: CKB::Utils.bin_to_hex(File.read("/tmp/udt.rb")))
api = CKB::API.new
wallet = CKB::Wallet.from_hex(api, '0xd00c06bfd800d27397002dca6fb0993d5ba6399b4238b2f29ee9deb97593d2bc')
alice = CKB::Wallet.from_hex(api, CKB::Key.random_private_key)
issue_amount = CKB::Utils.bin_to_hex('10000')
tx = wallet.generate_tx(alice.address, CKB::Utils.byte_to_shannon(8000), issue_amount, fee: 5000)
tx.cell_deps.push(mruby_dep)
tx.outputs[0].type = udt
api.send_transaction(tx.sign(wallet.key))
成功发行之后,我们将这 10000 个 UDT 中的 3000 个转移到另外一个随机私钥地址
bob = CKB::Wallet.from_hex(api, CKB::Key.random_private_key)
transfer_udt_amount = CKB::Utils.bin_to_hex('3000')
remain_udt_amount = CKB::Utils.bin_to_hex('7000')
alice.skip_data_and_type = false
tx = alice.generate_tx(bob.address, CKB::Utils.byte_to_shannon(4000), transfer_udt_amount, fee: 5000)
tx.cell_deps.push(mruby_dep)
tx.outputs[0].type = udt
tx.outputs[1].type = udt
tx.outputs_data[1] = remain_udt_amount
api.send_transaction(tx.sign(alice.key))
需要注意的是,这里使用了 CKB ruby sdk Wallet
相关的方法来实现交易的组装,更灵活的方式是手工构建一个交易结构 ( https://github.com/nervosnetwork/rfcs/blob/master/rfcs/0022-transaction-structure/0022-transaction-structure.md ) ,能够更灵活地实现 UDT 收集和转账,但是相关演示的 ruby 代码会变得很冗长,在实际运用中,可以用 ruby sdk 封装出更好的 TX Geneartor
我们用了 33 行 ruby 代码完成了一个基本的 UDT 合约,但这里有很多可以改进的点,举3个例子
- 这个演示的 UDT 合约使用了 Script 结构上的 args 来保存合约代码,会导致需要很多 CKB capacity 来构建 UDT tx,同时还把 issuer script hash 写死在了合约里面,不够灵活。我们可以通过改进 mruby 解析器,将原先从 args 读取合约代码的方式改成从 deps 读取 ( https://github.com/xxuejie/ckb-mruby/blob/6e0329c5a1c9002525a6b1b935c2ff99e5238edf/c/entry.c#L30 ) 这样就可以先部署一个合约,然后将它作为 tx deps 引用,Script 的结构可以优化成如下数据,任何人都可以引用这个 deps,通过修改 args 来一键发行属于自己的 UDT
Script:
code_hash = 'udt contract hash'
hash_type = 'data'
args = 'issuer script hash'
-
mruby 使用了抛出异常
raise 'xxxx'
来统一返回错误码-2
,不方便反馈具体的错误给用户,可以通过改进 mruby 解析器,改成用不同的返回值 https://github.com/xxuejie/ckb-mruby/blob/6e0329c5a1c9002525a6b1b935c2ff99e5238edf/c/entry.c#L41-L52 -
CKB Ruby SDK 不够灵活,目前收集 UDT 余额和构建交易比较繁琐,我们可以在 Ruby SDK 基础上实现一个 UDT generator,方便我们进行 UDT 发行和转账