比特币全方位解读

比特币也算是一个老年网红了,在这宽泛意义上的十周年之际,聊一聊自己对它的认识。

本文结合自己对比特币的了解及技术研究,在技术上分享一下自己的知识了解,同时从通俗的角度简析一下比特币的整体理念。本着技术博客的角度,这篇文章里面尽量不涉及金融、人性、社会、经济等问题,只谈技术。

何谓区块链

区块链已成为老生常谈的名词,网络上有大量文章针对它进行翻来覆去的讲解,故此本文不再赘述。具体可以参考我的另一篇文章:区块链漫谈

比特币解读

如果你对区块链、分布式记账已经有一定程度的了解,那么比特币的本质也就比较浅显易懂了。

比特币可以视为BitCoin公链内部的法币,它的直接使用价值在于BitCoin公链网络运行时的手续费以及交易价值等,但更大的价值在于稀有度以及区块链信仰

比特币来源于BitCoin公链上新区块诞生时对矿工的奖励,具体挖矿策略便是知名的工作量证明机制。本文的侧重点在于上层应用,因此挖矿相关的内容也不再深入探讨,接下来主要分析区块、地址、交易等相关技术细节。

公链对接

作为区块链数字货币的上层应用,最重要的问题便是对接BitCoin等公链。

对于大多数交易所、钱包等上层应用而言,其对接公链的技术方案往往是直接启动全节点(Full-Node),然后使用全节点暴露的http/api接口等进行地址、余额、交易的管理控制。对于开发而言,甚至不需要了解公链具体技术原理,只需要熟悉api接口的使用方式即可。

但是为了更加彻底的剖析相关的技术,本文不直接使用全节点钱包所封装好的API接口,而是根据标准算法和协议实现各个功能模块。当然为了避免重复造轮子做无用功,接下来仍然会使用一些相关的开源库封装好的library,尤其是加密算法、TX序列化等。

无节点模式

本文为方便读者更加直观、简单地了解BTC,将直接使用Cloud-Node而不使用Local-Full-Node

BTC发展十年之久,钱包节点数据之庞大将近1T,对于新手非常不友好。同时世界上有许多公共节点,它们会维护一个完整的公共节点并且暴露explorerapi给大家使用。因此对于绝大多数场景,完全不需要本地启动钱包节点,直接使用公共节点即可,本文选择了这个公共节点

问题:从这种公共节点上读取、发送交易安全吗?具体答案见下文。

区块浅析

众所周知,区块链是由无数前后相连、延绵不绝的区块组成。类似于数据结构中的链表,每个区块都拥有指pevious区块的指针,除此之外还有许多其他的属性。

如果你本地安装了BitCoin钱包且同步了数据的话,就可以进入数据区块目录blocks中,它内部会有许多*.dat文件,每个文件即代表一个区块。区块数据以自定义的格式持久化存储在这些数据文件中。

简而言之它并没有什么复杂的序列化算法,更像是网络协议中的数据包一样,按照约定的字节顺序填充。对于喜欢深究的朋友,可以参考以下BitCoin公链源代码

如果你没有本地钱包也没有关系,下面这个章节直接用公共节点的数据来分析。

直观的区块结构

BitCoin钱包解析*.dat后的数据结构大致是这样的,点击查看完整数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"hash": "0000000000000000002b5976abc3294d125a8e18c27207c9e3fe14e1d80ed574",
"height": 566041,
"total": 902743633145,
"fees": 28066086,
"size": 913052,
"ver": 536870912,
"time": "2019-03-07T11:26:25Z",
"received_time": "2019-03-07T11:26:25Z",
"coinbase_addr": "",
"relayed_by": "34.239.86.252:8333",
"bits": 388914000,
"nonce": 1510685924,
"n_tx": 2819,
"prev_block": "0000000000000000000f8d0574f47034b4f84b24b7b38a2fe279df38ab2055e0",
"mrkl_root": "b8778d480fc858f86bc9847e7b254c096982150bbcba8e9d900a4bb3c46001d1",
"txids": ["5718eba0747f11288c1bdbd3df86cded9ef821626761564e7ddf9c79c78552fd"],
"depth": 0,
}

需要明白的是,这个API服务本身对区块数据有一些封装和点缀。交易核心数据包括几种:

  • hash:当前区块的哈希
  • nonce:当前区块的Nonce
  • height:当前区块的块高,主链上的区块始终是递增的
  • total/fees:当前区块的交易金额及手续费
  • txids:当前区块内部的交易哈希列表
  • n_tx:当前区块内部的交易数量
  • prev_block:前一区块哈希,这个非常重要,在交易确认前出现区块回滚时需要及时处理。

区块问题

熟悉MySQL的同学应该知道binlog,我们可以把区块理解为整个公链的binlog

问题在于,MySQLbinlog只会被保留一段时间,但是区块链不可以,它就像一个分布式的数据库,所有的操作都记录在区块(binlog)中。区块链的分布式节点部署启动之后,它需要加载全部的历史区块数据,确保整个区块链上下文数据完全一致,才可以正常使用。

这个区块同步的数据量是非常相当庞大的,并且与日俱增。之前工作中出现过运维同学把节点数据误删除,然后钱包同学不得不等待区块数据重新同步,并且整个过程非常缓慢。

地址处理

如何抽象地理解BTC地址是一个非常棘手的问题。把它想象成密码箱、门牌号等等都不够贴切,我认为任何与物理实体直接映射的比喻都容易引起混淆,反而不容易恰当的理解它。

理解地址之前,我们先把整个区块链当做一个账房先生。所有比特币都在账房先生的口袋里面,我们所有人都不可能直接接触到它,因为是虚拟的嘛。并且整个区块链世界里大家都是透明的,账房先生也不认识任何人,那么如何确定资金归属呢?这就是BTC地址,我们眼中的BTC地址在账房先生眼中就是一个谜题,一个由虚拟指令组成的谜题,只要你能够正确给出谜题的答案,那么账房先生就允许你使用这个地址上的资金。

我们看到的一串字符地址,其实就是Base58编码后的谜题,这个谜题也可以理解为一个算法问题,BTC采用一套脚本语言实现它,凡是解开了这个算法问题的请求方都可以使用这个地址上的资金。

具体脚本引擎可以查看Go语言源代码,地址的详细说明也可以查看这篇文章,写的很赞。

EC公私钥

BitCoin采用的EC算法是secp256k1,它与prime256v1并不一样,我最初使用后者进行开发,在测试时总是出现错误,经过仔细排查才才发现这个问题,也是够尴尬的。后者据说是因为其算法中用到了来源不明的种子,存在安全隐患而不被使用,许多公链都采用了secp256k1算法,但是也有个别公链使用prime256v1,这个细节我将来会在单独的文章中讲一讲。

EC私钥没有特别的要求,可以直接采用随机数,例如:

1
byte[] prikey = RandomUtils.nextBytes(32);

根据私钥计算公钥也比较简单,直接利用bouncrycastle提供的椭圆算法即可:

1
2
3
boolean compressed = true; // 压缩前的pubkey为64byte
ECPoint point = secp256k1.getG().multiply(new BigInteger(1, prikey));
byte[] pubkey = point.getEncoded(compressed);

P2PKH地址的算法实现

对于平台应用而言,最常用的地址就是P2PKH了,此类算法实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static String genAddress(byte[] prefix, byte[] pubkey) {
byte[] pubkeyHash = Utils.sha256hash160(pubkey);
byte[] addr = new byte[prefix.length + pubkeyHash.length + 4];
// 处理前缀
System.arraycopy(prefix, 0, addr, 0, prefix.length);
// 处理哈希
System.arraycopy(pubkeyHash, 0, addr, prefix.length, pubkeyHash.length);
// 处理校验和
byte[] checksum = Sha256Hash.hashTwice(addr, 0, prefix.length + pubkeyHash.length);
System.arraycopy(checksum, 0, addr, prefix.length + pubkeyHash.length, 4);

return Base58.encode(addr);
}

以上只是P2PKH地址,它在交易时会作为P2PKH脚本的组成部分。

P2PK和Multi-Sig

P2PK与P2PKH类似,但是目前基本上没有人再使用前者了。

Multi-Sig多用在安全性较高、存储金额较大的业务场景中,对于具体应用而言,它可以作为大额冷钱包。抽象的讲,它是一种复杂度更高的“谜题”,与P2PKH类似,如果想花Multi-Sig地址上的钱,你也需要提供谜题的谜底,两者的区别在于它可能需要不止一个EC私钥。

理论上BitCoin公链的脚本引擎可以组合出无数种地址类型,只要主流节点愿意支持它们,因此未来很有可能会出现更多的地址类型。

交易分析

在这个章节中,我们重新认识一下UTXO,然后再从技术角度,创造并发送一个BTC交易,当然是TestNet上。

认识UTXO

关于UTXO,已经有许多文章专门进行讲解,在这里我也简单聊一下自己的理解。

众所周知,BitCoin是一个分布式记账系统,但是不要被“记账”一词误解。就像我之前讲到的一样,BitCoin并没有直接汇总数据,也就是说“没有总账”,它只有从2009年以来积累的TB级binlog。每个地址上的BTC金额,在BitCoin网络中并不是一个汇总数字,而是许多交易输出的指针引用。

通俗一点比喻的话,BTC就像是现实中的黄金。在每一笔交易中,付款方拿出的黄金会被敲碎或铸造,输出的碎金或金锭就直接放在收款方的地址上。如果收款方想在下一次交易中使用这些黄金的话,就必须明确指定自己要使用哪几块碎金进行交易。

这个概念非常重要,因为在传统的线上(B2B/B2C)交易中,用户的资金都只是数字,进行交易时只需要指定金额即可。就好像你的钱包里有2张100元,在交易中使用“哪一张100元”并没有什么区别,你只需要指定总金额即可。

但是在BitCoin网络中,这个区别就非常大了,其根源就在于UTXO。UTXO就是前面比喻中讲到的碎金,BitCoin网络中的交易必须明确指定UTXO,也就是说付款方必须明确指定使用“哪一张100元”参与交易。

黄金可以被砸的粉碎,UTXO也可以在交易中从1.0拆分为上千万个0.00000001。碎金可以被重铸为大额金锭,UTXO也可以从无数个0.0000001合并为1.0。当然这个过程并不是免费的,你需要付出昂贵的网络手续费。

为了从实践中间真知,接下来我们切入主题,直接参与BitCoin网络的交易,从而更深刻的理解它。

认识BitCoin网络

类似于普通互联网产品开放一样,BitCoin也有类似于devbetaprod环境,也就是RegTestTestNetMainNet。尽管具体称呼没有标准,但是各个环境都有各自的配置参数,比如C++版实现与Go语言实现,采用的是相同配置参数。

具体参数配置参见:https://github.com/bitcoin/bitcoin/blob/master/src/chainparams.cpp

BitCoin有许多分叉链、山寨链,这些公链的主要区别就在于上面链接中的某些配置参数。未来讲到其他公链时,我会专门提到这些参数配置的变化。

如今比特币非常昂贵,主链中的交易手续费也相当的高,因此本文选择测试链TestNet进行交易。

千万不要认为使用测试链就简单了,即便是本地运行测试节点,也需要同步2012年以来的近百GB的区块数据,并且测试网络的同步速度并不快,我尝试同步过一次,跑了一晚上也只有大概17%然后便直接停止,简直令人绝望。

如文章开头所说,本文接下来直接采用Cloud-Node进行交易,即直接使用 https://live.blockcypher.com/btc-testnet/ 提供的测试链开放接口。

申请测试币

在进行交易之前,需要先创建自己的BTC地址,同时申请测试币。地址的创建如上文说讲,测试币可以到TestNet水龙头里申请。

我在这一步使用的收款地址是mnwJkDQZJCkVLjr2A9oxkC3Zpr1zd8WC8F申请到了0.11313083个BTC

UTXO便是链接中JSON输出的outputs字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[
{
"value": 11313083,
"script": "76a9145163ef6c1a005249cef965cf5d917fddef26091b88ac",
"addresses": ["mnwJkDQZJCkVLjr2A9oxkC3Zpr1zd8WC8F"],
"script_type": "pay-to-pubkey-hash"
},
{
"value": 2337273,
"script": "a91402bed934bbc14ce4a25ea21e597cba51c67ebe8087",
"spent_by": "7be222f0243ad009471a87a4bc61293e9164934beca9c4db01057d423b4a513a",
"addresses": ["2MsVjy3vJxDY7W9eDaCBgJCKZU9KBzmHGbs"],
"script_type": "pay-to-script-hash"
}
]

UTXO是一个抽象的术语,在具体的交易中,它包括valuescript以及index,所谓index即是它在outputs数组中的序号,如上面的outputs中就是0序号金额为11313083的数据就是我们接下来要用的UTXO。至于script就是所谓的谜题,即使用这笔钱的凭证,你需要用私钥解开这个谜题来使用这笔钱。

接下来我们根据水龙头发送给我们的UTXO进行下一笔交易。

创建交易

创建交易的话,首先需要梳理清楚需要的输入参数以及输出参数。

输入参数包括UTXO信息以及使用UTXO所需要的EC密钥,同时需要指定手续费。手续费并不是由BTC网络自动征收的,而是由创建交易的人自己决定,当然手续费太低的话是没有旷工愿意搭理你的。

输出参数即是输入的BTC如何分配,即收款方以及收款金额。需要注意的是,”输入总额>输出总额+手续费“的话,剩余的钱就会被记账节点”黑掉”,所以一定要确保让余额返回自己的钱包地址。

以下是我用Go语言写的创建交易的代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 准备UTXO
utxoTx, _ := chainhash.NewHashFromStr("3ffcf5b6f000158cd907910a57680221aa8e0e780cb5b192f4b57905dcb91a2e")
utxoIndex := 0
utxoValue := int64(14829803)
fee := int64(100000)
// 新建TX
tx := wire.NewMsgTx(wire.TxVersion)
// 增加inputs, 输入为由水龙头产生的UTXO
txIn := wire.NewTxIn(wire.NewOutPoint(utxoTx, utxoIndex), nil, nil)
tx.AddTxIn(txIn)
// 增加outputs, 收款方还是自己, 应该是PubKeyHash
script, _ := txscript.PayToAddrScript(recvAddr)
tx.AddTxOut(wire.NewTxOut(utxoValue-fee, script))

需要注意的是,上面代码片段中的交易收款方仍然是付款方地址,但是正常交易中一般不应该是这样的。不过如果你想长期使用同一个地址的话,你可以在每一笔交易中都使用付款方地址作为找零地址。

像BTC、ETH等公链,对付款方与收款方并没有限制,但是也有一些区块链对它们有限制,具体情况讲到它们时候再详细说明。

证明UTXO的所有权

如果想在交易中使用UTXO,则必须证明你有权使用它,具体方法就是证明你拥有UTXO所属BTC地址的EC私钥。

对于P2PKH地址而言,大体上证明分两步进行:

  1. 正确的EC公钥:由于P2PKH地址中只有EC公钥哈希值,对于区块链中第一次出现的新地址而言,只有真正的拥有者才能拿出正确的EC公钥。
  2. 正确的EC签名:UTXO真正的所有权证明是EC私钥,当然不需要直接公布它。你需要在交易的inputs和outputs准备完毕后,使用EC私钥对交易整体进行EC签名,公链的矿工会根据EC公钥验证签名的正确性。

许多安全专家建议“不要重复使用BTC地址”,即交易后即直接废弃旧地址。

这个建议的理由其实很简单,已经被使用过的BTC地址,它的EC公钥已经暴露在公链上,相对而言破解它的步骤就少了一步,安全性也相应的降低了一些。

交易签名

交易签名有两层意义。首先需要验证UTXO的所有权,即通过EC私钥签名证明了自己可以使用这笔钱。其次需要对整个交易进行签名,包括inputsoutputs,签名之后其他人如果修改了任何一个属性都会导致签名失效、交易作废。

执行交易签名的Go语言代码片段如下:

1
2
3
4
utxoPriKey := nil
utxoScript, _ := hex.DecodeString("76a9143f891d65ef4c189a4cc58594b8e2b018db8f3e6088ac")
sign, _ := txscript.SignatureScript(tx, 0, utxoScript, txscript.SigHashAll, utxoPriKey, true)
tx.TxIn[0].SignatureScript = sign

内部的签名算法细节参见:https://github.com/btcsuite/btcd/blob/master/txscript/script.go#L599

回到前文中讲到的”使用公共节点是否安全“的问题,在这里就有答案了。因为交易数据中存在EC签名,即便没有一个可靠的本地节点,直接通过公共节点广播交易,也不存在数据篡改风险。因为中间节点没有办法篡改交易数据的EC签名,也就是说中间节点篡改交易数据后EC签名就会失效,它最多只能阻止交易的广播而不能修改它。

对于公链硬分叉而言,交易签名就可能存在重放攻击的风险,这个细节后续再详谈。

广播交易

在交易创建、签名完成后,直接将它广播出去就可以静静地等待结果。

首先需要将交易序列化为16进制数据,在上文中我们已经准备好了wire.MsgTx实例,拿着它可以直接序列化:

1
2
3
4
5
6
7
8
// 将TX序列化为为Hex格式字符串
func txToHex(tx *wire.MsgTx) string {
buf := bytes.NewBuffer(make([]byte, 0, tx.SerializeSize()))
if err := tx.Serialize(buf); err != nil {
print("err: ", err)
}
return hex.EncodeToString(buf.Bytes())
}

序列化算法细节参考:https://github.com/btcsuite/btcd/blob/master/wire/msgtx.go#L681

整个序列化过程其实非常简单,就是按照约定的字段顺序将各种数据顺序写入字节数组中,解析交易时再按照顺序反向读取即可。以下是我测试过程中记录的一个交易数据:

01000000012e1ab9dc0579b5f492b1b50c780e8eaa210268570a9107d98c1500f0b6f5fc3f000000006b483045022100a3f1b27d58125dfdbf1d99bbc3b30e607d39d0350459a1cbb3f30596208477bc022049781a095fbe637564a5b0163c6b8366020b713871c6f53d2d49a41ab6c686380121023db035782e77d6914df2b7db9fa699d4cb6a9c95a78a57564cdb5600517326d1ffffffff014bc2e000000000001976a9143f891d65ef4c189a4cc58594b8e2b018db8f3e6088ac00000000

可以使用Decode Transaction这个在线工具解码上面的交易数据,查看完整的交易输入、输出等数据。

可以使用Broadcast Transaction这个在线工具广播上面的交易数据,广播完毕后等待公链确认数即可。

注意:以上全部是TestNet的工具链接,只支持TestNet交易

交易的背后故事

细究广播交易的过程的话,需要再提一次分布式记账的概念。整个BitCoin网络由许多节点组成,每个节点都可以参与记账,但是每隔一段时间最终生效的账单只有一份,这就意味着一段时间内只有一个节点所提供的账单是有效的,而这个节点就是大家熟知的矿工。矿工负责整记账服务,支撑着整个区块链的运转,同时它也会获得手续费收益以及挖矿奖励收益。

交易的创建、签名都可以离线进行,只是最终的交易数据需要推送给矿工节点,由它将你的交易记录在区块链中,顺利的话,整个过程耗时大概10分钟。比较标准的做法是,本地启动Full-Node对接公链,不过这种方式太过笨重,本文采用了Cloud-Node

交易的创建、签名都可以离线进行,只是最终的交易数据需要推送给矿工节点,由矿工节点将交易记录在区块中,顺利的话,整个过程耗时大概10分钟。当然矿工也不是乐于助人的雷锋同志,它也会看您提供的手续费,如果手续费过低则很有可能被矿工直接无视,从而导致交易长时间无法录入区块链中,因此得不到区块确认。对于常规的BTC钱包而言,如果存在未确认的交易,你的资金也会被长时间的冻结而无法使用。

这种情况对于小白用户相当常见,其实解决办法也很简单。直接绕开BTC钱包,直接使用旧UTXO发送一笔新交易,就可以把旧交易给“踢掉”。因为旧交易的手续费太低而被无视,此时它消费的UTXO对于整个区块链而言是unspend,所以你可以直接使用它进行新的交易。新交易被区块链确认之后,旧交易就属于双花非法交易而被拒绝。

钱包详解

区块链钱包的管理也是一门大学问,包括冷热钱包分层确定性钱包等等。为避免本文过于臃肿,这一部分内容单独放在下一篇文章中。

后言

本打算在这篇文章中系统全面地讲一下BitCoin的技术细节,但是我严重低估了它的工作量。

花费了许多时间撰写这篇文章之后,发现整篇文章也变得越来越臃肿,遂决定分篇讲解,部分内容挪至单独的文章中,敬请期待。