比特币符文(Runes)在技术原理上比较简单,很容易理解,实现 Runes 只需要用到比特币脚本中的 OP_RETURN
操作符。也是正因为简单,所以在技术特性上, Runes 相对干净一点,没有铭文和 RGB 那么复杂的链下状态。而 Runes 厉害的地方在于,能把这样一个生态给玩起来,虽然现在也凉了,但是我们不那么关心市场表现,从技术的角度,非常切实的看一下 Runes 是如何实现的,并且我们自己会一步一步开发一个简化的 Runes 协议出来。当我们真正理解了 Runes 协议,就可以直接看懂更复杂的项目,比如 Alkanes,一个在比特币上支持 WASM 智能合约的协议。
这篇文章的操作基于《比特币脚本开发教程》中的知识,如果缺少对比特币脚本的基本了解,可以先看一下基础教程。
首先把 Runes 相关的操作定义为 json 格式的数据结构。用什么语言都可以,这里用的是 Rust。为了简化教程,可以省去一些实际中常用的元素,比如 transfer
的操作定义中,应该允许一次性转账给多个目标地址,但这里只有一个目标地址,没有用数组:
struct IssueRune
{
op: u8, // 固定为 0,代表发行
symbol: String, // Rune name
supply: String, // 发行总量
}
struct TransferRune
{
op: u8, // 固定为 1,代表转账
id: u64, // rune_id
vout: u32, // 转账目标
amount: String, // 转账金额
}
这个结构定义中,唯一可能有点迷惑的是 vout
字段,一般我们要转账给一个目标地址的时候,会使用目标地址的字符串作为值,但是 Runes 协议中,为了节省链上空间,使用 vout
也就是当前这笔交易、第几个输出的索引作为 Runes 转账的目标。因为每一笔交易的第 0 个输出会包含 OP_RETURN
的值,转账操作的字符全部会放到 OP_RETURN
的内容中,所以在一笔交易里,vout
只用数字就足以表明转账的目标地址是哪个。交易数据的结构大概是这样:
tx {
vin: [...]
vout: [
{ vout: 0, scriptPubKey: OP_RETURN <json数据> },
{ vout: 1, scriptPubKey: OP_0 <转账地址1> },
{ vout: 2, scriptPubKey: OP_0 <转账地址2> }
]
}
接着给数据结构定义一下序列化函数,让结构体可以转变为 json 字符串:
impl IssueRune
{
fn toJson(&self) -> String
{
format!(
"{{\"op\":{},\"symbol\":\"{}\",\"supply\":\"{}\"}}",
self.op, self.symbol, self.supply
)
}
}
impl TransferRune
{
fn toJson(&self) -> String
{
format!(
"{{\"op\":{},\"id\":{},\"vout\":{},\"amount\":\"{}\"}}",
self.op, self.id, self.vout, self.amount
)
}
}
接下来的操作都基于本地启动的 regtest 节点,所以记得先启动起来,同时验证下有没有加载钱包、钱包里有没有余额。然后准备一下要发行 Rune 的 json 数据,比如这样,发行的 Rune 叫 Doge
,总发行量 1000
个:
fn issue_rune()
{
let issue = IssueRune
{
op: 0,
symbol: "Doge".to_string(),
supply: "1000".to_string(),
};
println!("Issue Doge JSON: {}", issue.toJson());
}
运行这个函数,就会得到这样的输出,后面的 json 数据很重要,我们稍后会把这个数据发送到链上:
Issue Doge JSON: {"op":0,"symbol":"Doge","supply":"1000"}
然后运行这个命令行,把 json 数据转变为 16进制字符串:
echo -n "{"op":0,"symbol":"Doge","supply":"1000"}" | xxd -p -c 999
我得到了这样的输出:
7b6f703a302c73796d626f6c3a446f67652c737570706c793a313030307d
这就是会放到 OP_RETURN
后面、用来上链的数据。注意 OP_RETURN
最多支持 80 个字节,所以这个数据不能太长。
接着查看并挑一笔未花费的输出,因为 Runes 所有的操作都必须绑定到 UTXO 上。用这个命令查看你的钱包有哪些 UTXO 可用,然后挑一个你喜欢的:
bitcoin-cli -datadir=./ -regtest listunspent
比如我要用的 UTXO 是这样:
{
"txid": "8bfd524e9fc150dab11289d7e6d07860b2b5d6acb54b278a5dc1d1d7631bc8fa",
"vout": 0,
"address": "bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw",
"amount": 50.00000000,
// ...
}
然后生成一个找零地址,用来接收比特币余额,这里用了 legecy 格式的地址,这个不是强制的,用 SegWit 的地址也不影响:
bitcoin-cli -datadir=./ getrawchangeaddress legacy
我生成的地址是 n4Ybvvzm9vRQepuMpXBnTWWbYuTgsPSZCV
,接下来可以用这个地址构建交易了:
bitcoin-cli -datadir=./ createrawtransaction \
'[{"txid":"8bfd524e9fc150dab11289d7e6d07860b2b5d6acb54b278a5dc1d1d7631bc8fa","vout":0}]' \
'[{"data":"7b6f703a302c73796d626f6c3a446f67652c737570706c793a313030307d"},{"n4Ybvvzm9vRQepuMpXBnTWWbYuTgsPSZCV":49.99}]'
注意这个命令给找零地址的金额为 49.99,这种操作是不可以直接在主网使用的,需要精确计算余额和手续费的差值,然后给找零地址,不然会有很大的资金损失。这里只是懒得计算精确值。
生成交易数据后,对交易进行签名:
bitcoin-cli -datadir=./ signrawtransactionwithwallet 0200000001fac81b63d7d1c15d8a274bb5acd6b5b26078d0e6d78912b1da50c19f4e52fd8b0000000000fdffffff020000000000000000206a1e7b6f703a302c73796d626f6c3a446f67652c737570706c793a313030307dc0aff629010000001976a914fc9ab9cd801c625c9fe323fe669e6a3e362eed8088ac00000000
发送签名后的交易到链上:
bitcoin-cli -datadir=./ sendrawtransaction 02000000000101fac81b63d7d1c15d8a274bb5acd6b5b26078d0e6d78912b1da50c19f4e52fd8b0000000000fdffffff020000000000000000206a1e7b6f703a302c73796d626f6c3a446f67652c737570706c793a313030307dc0aff629010000001976a914fc9ab9cd801c625c9fe323fe669e6a3e362eed8088ac02473044022004a2553cc5348dd4521c093149b0ba5e5603fe4134d06a455e12abeac097ea19022076e72632b2488e1316e54559ed733b37de9ce7fd04119e78a59546a3d2c1faea0121020b396a9dfa1655feef066fe03b403d3e4bdee41ef9b26551497c0921acbf6bc100000000
要留意这个命令会输出一个 txid,这个 txid 比较重要,我们后续会从这个 txid 来转出 Doge 代币,所以要记得留下这个 txid 的记录,我的交易哈希是:e2061d0b8b2f98ee47ba6564c1e7409872432354c7617d278fe0e8c4485ff04a
。挖一个区块来确认交易:
bitcoin-cli -datadir=./ generatetoaddress 1 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw
如果一切顺利,交易数据就应该已经上链了。那么链下的解析器在拿到这笔交易后,会看到发行 Rune 的操作,并且解析出来。如果想确认下 OP_RETURN
的数据是不是写对了,可以解码一下刚才广播的交易详情:
bitcoin-cli -datadir=./ decoderawtransaction 02000000000101fac81b63d7d1c15d8a274bb5acd6b5b26078d0e6d78912b1da50c19f4e52fd8b0000000000fdffffff020000000000000000206a1e7b6f703a302c73796d626f6c3a446f67652c737570706c793a313030307dc0aff629010000001976a914fc9ab9cd801c625c9fe323fe669e6a3e362eed8088ac02473044022004a2553cc5348dd4521c093149b0ba5e5603fe4134d06a455e12abeac097ea19022076e72632b2488e1316e54559ed733b37de9ce7fd04119e78a59546a3d2c1faea0121020b396a9dfa1655feef066fe03b403d3e4bdee41ef9b26551497c0921acbf6bc100000000
输出的结果是这样:
"vout": [
{
"value": 0.00000000,
"n": 0,
"scriptPubKey": {
"asm": "OP_RETURN 7b6f703a302c73796d626f6c3a446f67652c737570706c793a313030307d",
// ...
}
},
{
"value": 49.99000000,
"n": 1,
"scriptPubKey": { //... }
}
]
很明显看到了在第 0 个输出中,有 OP_RETURN 十六进制数据
的内容,如果还是不放心,可以 decode 一下 16进制字符串:
echo -n "7b6f703a302c73796d626f6c3a446f67652c737570706c793a313030307d" | xxd -r -p
应该得到:
{op:0,symbol:Doge,supply:1000}
刚才创建了 Doge
代币,发行量是 1000
,我们接下来通过一笔转账交易,来转出这 1000 个 Doge。
首先得计算一下 rune_id,也就是我们刚才发行的 Doge
的唯一 ID 是什么,因为 Symbol 字符串是有可能重复的,而且占用字符空间也多,一般会做一些工程上的折中,比如对 txid 按照字节反序,然后取前 8 字节,得到一个 u64 长度的数字,像这样:
fn calc_run_idby_txid()
{
let txid = "e2061d0b8b2f98ee47ba6564c1e7409872432354c7617d278fe0e8c4485ff04a".to_string();
// 按字节反序,然后取前 8 字节
let mut bytes = hex::decode(txid).unwrap();
bytes.reverse();
let run_id = u64::from_le_bytes(bytes[0..8].try_into().unwrap());
println!("Run ID: {}", run_id);
}
这个函数运行后会得到 10367542271932362826
,我们把这个数字作为 rune_id,去构建转账 rune 需要的 json 数据:
fn transfer_rune()
{
let transfer = TransferRune
{
op: 1,
id: 10367542271932362826,
vout: 1,
amount: "1000".to_string(),
};
println!("Transfer Rune JSON: {}", transfer.toJson());
}
这个参数里要留意 vout
的值,它是接下来构建交易的时候,要转出到某个地址的 vout 的索引,和创建代币时候的交易没有任何关系。代码运行后得到这样的结果:
Transfer Doge JSON: {"op":1,"id":10367542271932362826,"vout":1,"amount":"1000"}
接下来就可以重复之前的步骤,把 json 转为 16进制字符串:
echo -n "{"op":1,"id":10367542271932362826,"vout":1,"amount":"1000"}" | xxd -p -c 999
我得到 7b6f703a312c69643a31303336373534323237313933323336323832362c766f75743a312c616d6f756e743a313030307d
。
创建一个新地址用于接收 Doge:
bitcoin-cli -datadir=./ getnewaddress
我的新地址是:bcrt1qc250507tws9z9wkurfcv3jue2nls6npzaqt7ka
。
利用刚才得到的参数,组装一笔转账 Doge 的交易:
bitcoin-cli -datadir=./ createrawtransaction \
'[{"txid":"e2061d0b8b2f98ee47ba6564c1e7409872432354c7617d278fe0e8c4485ff04a","vout":1}]' \
'[{"data":"7b226f70223a312c226964223a31303336373534323237313933323336323832362c22766f7574223a312c22616d6f756e74223a2231303030227d"},{"bcrt1q0n2x7030x59j5ql9pp6mw0tps74ag0znrdp45r":0.01},{"n4Ybvvzm9vRQepuMpXBnTWWbYuTgsPSZCV":49.9798}]'
这里和之前的交易略有不同,包含两个输出,第一个是接收 Doge 的地址,金额随意,因为重点在于 Doge 余额,而不是 BTC 余额。第二个参数则是找零地址,我们前面的交易里用到过。
剩下的操作轻车熟路,对这笔交易签名、把交易广播出去、挖一个新区块让交易确认:
# 对交易签名
bitcoin-cli -datadir=./ signrawtransactionwithwallet 02000000014af05f48c4e8e08f277d61c7542343729840e7c16465ba47ee982f8b0b1d06e20100000000fdffffff0300000000000000003d6a3b7b226f70223a312c226964223a31303336373534323237313933323336323832362c22766f7574223a312c22616d6f756e74223a2231303030227d40420f00000000001600147cd46f3e2f350b2a03e50875b73d6187abd43c53601fe729010000001976a914fc9ab9cd801c625c9fe323fe669e6a3e362eed8088ac00000000
# 广播交易
bitcoin-cli -datadir=./ sendrawtransaction 02000000014af05f48c4e8e08f277d61c7542343729840e7c16465ba47ee982f8b0b1d06e2010000006a47304402201437a9e83ae0c6842ebd9d355af9c7be1f6f2eaa070b5d7a6e02e13ca8f2d13102206d05753c428f526b8c6636022991591517cc7d7982badfc633519cb44715957a0121026f441e8156148d0bb4963edaff187873f9800a37bb5f0731256e38d632031283fdffffff0300000000000000003d6a3b7b226f70223a312c226964223a31303336373534323237313933323336323832362c22766f7574223a312c22616d6f756e74223a2231303030227d40420f00000000001600147cd46f3e2f350b2a03e50875b73d6187abd43c53601fe729010000001976a914fc9ab9cd801c625c9fe323fe669e6a3e362eed8088ac00000000
# 得到交易哈希:80709a25e5355d51ee6d7fb625c40e9c4c49b049afa3aca18aeaa03bc685c1f0
# 确认交易
bitcoin-cli -datadir=./ generatetoaddress 1 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw
到这一步,转账 Doge 的交易就完成并且上链了。
你也许有点纳闷,这不就是发了两笔普通的比特币交易吗,只是放了两个 json 数据到交易上。Runes 就是这样,所有的操作,就在 OP_RETURN
允许的那 80 个字节的空间里完成。链上只记录 Runes 的操作,而不维护 Runes 的最终状态。包括有哪些代币、代币余额等信息,全部需要链下程序根据协议进行解析,状态也全部在链下程序维护。
我们首先可以通过已知的交易哈希,获取到这两笔交易的全部详情数据。如果交易哈希不是已知,可以监听扫描全部区块的全部交易,然后按照协议约定解析就行了。这里简化一点。
在 Cargo.toml
文件中导入依赖包:
[dependencies]
hex = "0.4"
bitcoin = "0.31" # Script & consensus decode
bitcoincore-rpc = "0.18" # RPC client
serde = { version = "1.0", features = ["derive"] }
anyhow = "1.0"
通过 RPC 查询节点上的交易数据:
// 导入必要依赖
use bitcoin::{Transaction, Txid};
use bitcoincore_rpc::{Auth, Client, RpcApi};
use serde::Deserialize;
use std::path::PathBuf;
use std::str::FromStr;
fn parse_tx()
{
// 去启动比特币节点的数据目录下,找用来 rpc 鉴权的 cookie 文件
let mut cookie = PathBuf::from("/Users/smallyu/work/github/bitcoin-regtest");
cookie.push("regtest/.cookie");
let rpc = Client::new(
"http://127.0.0.1:18443",
Auth::CookieFile(cookie),
).unwrap();
// 我们已知的交易哈希
let issue_txid = Txid::from_str("e2061d0b8b2f98ee47ba6564c1e7409872432354c7617d278fe0e8c4485ff04a").unwrap();
let transfer_txid = Txid::from_str("80709a25e5355d51ee6d7fb625c40e9c4c49b049afa3aca18aeaa03bc685c1f0").unwrap();
// 这里会得到完整的交易数据
let issue_hex = rpc.get_raw_transaction_hex(&issue_txid, None).unwrap();
let transfer_hex = rpc.get_raw_transaction_hex(&transfer_txid, None).unwrap();
println!("Issue Hex: {}", issue_hex);
println!("Transfer Hex: {}", transfer_hex);
// 调用函数来解析交易
parse_op_return(issue_hex);
parse_op_return(transfer_hex);
}
这个函数在运行的时候,会从链上节点,查询出真实的已经上链的交易数据。接下来可以对这两笔交易的 Runes 操作做解析:
fn parse_op_return(tx_str: String)
{
let tx: Transaction = bitcoin::consensus::deserialize(&hex::decode(tx_str).unwrap()).unwrap();
let script = tx.output[0].script_pubkey.clone();
// OP_RETURN, DATA
let mut iter = script.instructions();
let mut op_return = iter.next();
let mut data = iter.next();
// 解析数据
match op_return
{
Some(Ok(op_return)) =>
{
match data
{
Some(Ok(data)) =>
{
match (data)
{
bitcoin::blockdata::script::Instruction::PushBytes(bytes) =>
{
let json_str = std::str::from_utf8(bytes.as_ref()).unwrap();
println!("{}", json_str);
}
_ => panic!("Expected OP_RETURN with data"),
}
}
_ => panic!("No data found in OP_RETURN"),
}
}
_ => panic!("No OP_RETURN found in script"),
}
}
解析数据的代码部分,写法上嵌套有点多,只是因为我不喜欢用语法糖。想看起来更舒服的话,也可以在代码写法上做调整,变得更精简,不过无论写法如何,代码干的事情都一样。这个函数会从交易数据里,经过层层解析,打印出这样的结果:
{op:0,symbol:Doge,supply:1000}
{"op":1,"id":10367542271932362826,"vout":1,"amount":"1000"}
这样,我们就看到了期望的两个 Runes 动作,第一个是发行 Doge,第二个是对 Doge 进行转账。
以上过程就是 Runes 协议比较核心的内容,剩下的只需要把链下程序扩充一下,记录 Runes 状态、根据 rune_id 关联 Rune 操作、储存和显示余额变更等信息就可以了。