共计 3070 个字符,预计需要花费 8 分钟才能阅读完成。
原文链接 https://github.com/liyue201/b…
如何设计一个比特币钱包服务
概述
总所周知,比特币全节点的实现目前有两个版本,一个是中本聪 c ++ 写的原版 bitcoin core,一个是 go 语言写的 btcd。这两个版本从功能上并没有多大差异,都实现了比特币协议。从理论上讲,比特币网络中的每个全节点只要遵循相同的比特币协议,至于用什么语言实现,用什么方式存储都是无所谓的。bitcoin core 和 btcd 都没有实现通用的钱包服务,这里说的钱包服务指的是一个可以给钱包 app 提供所数据的服务,私钥保存在钱包 app 中,用户交易在钱包 app 中签名,再将签名数据发给钱包服务,再由钱包服务转发到全节点上链,如图:
所幸的是全节点提供了一些 JSON RPC 接口,通过这些接口可以读取区块数据和发起转账交易。但是能读到的数据还是太原始了,就连获取某个地址的余额都做不到。因为比特币使用的是 UTXO 账号体系,并没有一个统一的地方存储地址余额,只有 UTXO。而 UTXO 是分散在每个区块中的,要想知道某个地址的 UTXO,需要从第一个区块开始遍历。有些人说这样设计可以更好的并发执行,这个鄙人真的不敢苟同。说可以更好的溯源倒是真的,因为每一笔交易都可以追溯到上一笔交易。
钱包服务功能
好了,废话不多说,我们看一下一个钱包服务应该具备什么样的功能。
(1)查询任意一个地址的余额,钱包需要知道自己地址的余额。
(2)查询任意一个地址的 UTXO,钱包转账时需要 UTXO 来签名打包。
(3)发起转账交易。钱包需要调服务发起转账交易。
(4)查询任意一个地址的历史交易记录。
(5)查询最新区块高度,钱包转账后通过区块高度来计算交易是否已经确认,比特币是 6 个区块确认。
第 (1)(2) 点实际上是一个问题,知道 UTXO 自然就知道余额了。第 (3)(4) 可以直接调全节点接口。所以钱包服务的难点只剩下 (2)(4) 这两个功能。钱包服务要做的就是从全节点从第一个区块开始遍历读取所有的交易记录,并推导出每个地址当前区块高度的 UTXO,再将这个信息保存到数据库中方便钱包使用。
全节点选择
bitcoin core 虽然是原版的比特币全节点,但是他只提供了 http 接口,没有 websocket 接口,不能实时推送未上链的交易。一个体验好的钱包不仅要获取已经上链的交易记录,也要知道未上链的交易。btcd 不仅提供了 http 接口,还有 websocket,所以 btcd 应该是更好的选择。
数据库选型
到目前为止(2018-12-26)比特币的交易量是 3.6 亿,因为一笔交易涉及到多个输入和多个输出地址(如果你研究过区块链浏览器上的数据,会发现一笔交易几千个输入和几千个输出都是很正常的,这样可以省矿工费,可能是交易所转账),解析出来后大约是 16.6 亿条(这个我自己跑的数据)。若是用传统的关系型数据库肯定扛不住的,所以一般人都会往 nosql 或者列式数据库方向考虑,比如用 mongo 或 cassandra 等。虽然能够解决问题,但是面对这么大的数据,这类型的数据库都需要部署分片集群。对于一个小创业公司来说,如果用户不是很多,从运维成本上来看,是不是都点杀鸡用牛刀大感觉呢。有没有经济实惠的方案?当然有,比如 leveldb 和 rocksdb 这类 kv 嵌入式数据库,只需要单个机器就可以满足要求。在几亿数据量情况下,leveldb 在普通硬盘上读写速度可以达到几万每秒,使用 ssd 硬盘更快,而且内存占用极少。当然使用 leveldb 或 rocksdb,也有其缺点,就是单机数据安全的问题,如果硬盘坏了怎么办。恰好我们这个业务对数据的安全性要求并不高,因为所有数据都是从全节点来的,要是硬盘坏了,大不了重新跑一遍数据。rocksdb 是 facebook 的开源产品,据说是对 leveldb 做了优化,性能更强。rocksdb 是 c ++ 写的,而 leveldb 除了 c ++ 版本之外,还有 go 语言版本,比特币全节点 btcd 和以太坊全节点 geth 都是用的 go 版本的 leveldb。最终我选择了 leveldb,因为我的服务是用 go 写的,虽然 go 也可以通过 cgo 调用 C ++,但是代码写起来没有纯 go 那么舒服。
数据结构
leveldb 是 kv 数据库,没有表的概念,所以数据结构的设计非常关键。比如地址的历史交易记录,在关系型数据库中可以建一个以地址为索引的历史交易记录表。而 kv 数据库则没有索引的概念。好在 level 的 key 是有序的,可以根据 key 的顺序读取内容。根据这个特性,我们可以这样设计索引,每个地址的交易是一组 key,这组 key 在数据库的所有 key 中是紧挨着的。这样只要知道某个地址的第一个索引 key 就可以按顺序读到这个地址的其他交易。因为一个交易涉及到多个输入地址和输出地址所以需要索引跟数据分开,对于 utxo 只关联到一个地址,就不需要单独设计索引了,索引跟数据可以放在一起。
数据结构设计好了,该怎么存。这个就是序列化的问题了,可以是 json,或者 go 自带的 gob,也可以自己写。最后从编码难度,执行效率和压缩率等方面综合考虑采用了 protobuf。这是我的 proto 文件
syntax = “proto3”;
package models;
//key: utxo-{addr}-{Transaction.id}-{Transaction.vout}
message Utxo {
string txId = 1;
uint32 vout = 2;
int64 amount = 3;
string scriptPubKey = 4;
}
//context
//key: context
message Context {
int64 blockHeight = 1; // 当前处理的区块高度
string blockHash = 2; // 当前处理的区块 hash
int32 blockTotalTx = 3; // 当前区块的交易数
int32 BlockProcessedTx = 4; // 当前区块已经处理的交易数
int64 totalTx = 5; // 总共的交易数
int64 totalTxNode = 6; //
}
message TxInput {
string txId = 1; // 上一笔交易 id
uint32 vout = 2; // 上一笔交易输出的 idx
string addr = 3; // 通过查询获得
int64 amount = 4; // 通过查询获得
}
message TxOutput {
string addr = 1;
int64 amount = 2;
uint32 vout = 3;
string scriptPubKey = 4;
}
//key: tx-{Transaction.txId}
message Transaction {
int64 id = 1; // 自增 di
string txId = 2;
int64 blockTime = 3;
string blockHash = 4;
int64 blockHeight = 5;
repeated TxInput inputs = 6;
repeated TxOutput outputs = 7;
}
//key: txnode-{addr}-{TxNode.id}
message TxNode{
int64 id = 1; // 自增 di
string txId = 2;
uint32 type = 3; //1-intput 2-output 3-intput|output
}
总结
这个项目的难点不在编码而是技术选型,当然还有调试。代码写完后经过漫长的同步,大概半个月才同步完成。只有同步完成才能进行完整的功能测试,若果有数据的 bug 还得从头开始同步。需要注意的是这里只是设计了已经确认的区块,对于未确认的区块和内存池的交易要单独考虑。