一、概述
在前面的课程中,我们使用节点软件的getnewaddress调用来创建 新的比特币地址,地址对应的私钥以及交易的签名都是由节点钱包模块 管理,应用程序是无法控制的,在某些应用场景中,这可能会限制 应用的功能实现。
如果要获得最大程度的灵活性,我们就需要抛开节点软件,使用 C#代码来离线生成地址。这些离线生成的地址自然不属于节点钱包 管理,因此也会带来一些额外的问题,例如:
需要我们理解密钥、地址、脚本这些比特币内部的机制需要我们自己进行裸交易的构造以及签名,而不是简单地调用sendtoaddress需要我们自己跟踪这些地址相关的UTXO,而不是简单地调用listunspent需要我们自己汇总比特币余额,没有一个getbalance可用
这些麻烦都是因为我们试图自己管理地址而引发的,从某种程度上说, 一旦我们决定自己管理地址,基本上就需要实现一个钱包模块了:
在接下来的课程中,我们还是使用NBitcoin来完成这些任务 —— 前面说过,NBitcoin 是最完善的.NET平台上的比特币协议实现,它不仅仅包含RPC的封装。
二、创建私钥和公钥
我们之前已经了解,从私钥可以导出公钥,从公钥则可以导出地址,地址只是 公钥的一种简明表达形式:
私钥本质上就是一个随机数,从私钥出发,利用椭圆曲线乘法运算 可以推导出公钥,而从公钥,利用哈希算法就得到比特币地址了。这两次 运算都是单向不可逆的,因此无法从地址反推出公钥,或者从公钥反推出 私钥。
地址源于密钥,因此让我们首先使用NBitcoin的Key类来创建公钥和私钥:
例如,下面的代码创建密钥对并显示私钥和公钥的16进制字符串:
Key key = new Key();Console.WriteLine("is compressed => {0}",key.IsCompressed); //是否压缩公钥?string prv = Encoders.Hex.EncodeData(key.ToBytes()); //16进制字符串Console.WriteLine("private => {0}",prv);Console.WriteLine("private wif => {0}",key.GetWif(Network.RegTest)); //WIF格式私钥PubKey pubKey = key.PubKey; //返回公钥对象Console.WriteLine("public => {0}",pubKey.ToHex()); //16进制字符串
压缩形式的公钥比非压缩的公钥差不多短一半,但使用上没有差异,因此 Key默认生成的都是使用压缩形式的公钥。可以使用IsCompressed属性 验证这一点。
公钥对象的Hash属性可以得到公钥的哈希值,这正是构造 比特币地址的核心数据,让我们先看一下它的样子:
KeyId hash = pubKey.Hash;Console.WriteLine("hash => {0}", hash.ToString()); //16进制字符串using NBitcoin;using NBitcoin.DataEncoders;using System;namespace NewKey{ class Program { static void Main(string[] args) { Key key = new Key(); Console.WriteLine("compressed => {0}", key.IsCompressed); Console.WriteLine("prv key => {0}", Encoders.Hex.EncodeData(key.ToBytes())); Console.WriteLine("prv key wif => {0}", key.GetWif(Network.RegTest)); PubKey pubKey = key.PubKey; Console.WriteLine("pub key => {0}", pubKey.ToHex()); Console.WriteLine("pub key hash => {0}", pubKey.Hash); Console.ReadLine(); } }}
三、创建P2PKH地址
在比特币网络中,地址的作用就是接收以太币,并以UTXO的形式呆在 交易里等待被消费掉。因此地址最初是与密钥相关的:因为密钥对应着 某个用户/身份。在比特币的演化过程中,陆续出现了若干种形式的地址, 但核心始终是一致的:标识目标用户/身份。
让我们从最简单的P2PKH地址说起。
P2PKH(Pay To Public Key Hash)地址是第一种被定义的比特币地址, 它基于公钥的哈希而生成:
P2PKH地址包含三部分:8位网络前缀、160位公钥哈希和32位校验码后缀,这三部分 内容拼接起来并经过base58编码,就得到了P2PKH地址。
地址前缀
由于比特币的P2P协议目前被应用到多个区块链中,例如比特币主链、测试链、 莱特币、dash币等,并且比特币有多种地址,因此使用前缀来区分不同的区块链 或地址格式。例如,对于比特币主链的P2PKH地址,其前缀为00;而对于测试链 的P2PKH地址,其前缀则为6F。不同的前缀经过base58编码过程后,则形成了 不同的前导符,使我们很容易区分地址的类型:
关于地址前缀的详细信息,可以参考官网说明。
NBitcoin针对不同的网络提供了对应的封装类,例如在这些网络封装类中标记了 不同的前缀方案。因此当我们生成地址时,需要指定一个网络参数对象,以便 正确地应用地址前缀:
例如,下面的代码获取开发私链模式下的网络参数对象:
Network network = Network.RegTest;
在NBitcoin中使用BitcoinPubKeyAddress类表征一个P2PKH比特币地址,基于上面 P2PKH地址的构成,容易理解,实例化一个P2PKH地址需要传入公钥哈希和网络参数。 例如,下面的代码创建一个新的密钥,并返回其在私有链模式下的地址:
Key key = new Key();BitcoinAddress addr = new BitcoinPubKeyAddress(key.PubKey.Hash,Network.RegTest);Console.WriteLine("address => {0}", addr);
由于密钥和P2PKH地址是一一对应的,因此,也可以按照私钥/公钥的途径直接 返回P2PKH地址,例如:
BitcoinAddress addr = key.PubKey.GetAddress(Network.RegTest);using NBitcoin;using System;namespace Newp2pkh{ class Program { static void Main(string[] args) { Key key = new Key(); PubKey pubKey = key.PubKey; Console.WriteLine("pub key => {0}", pubKey); KeyId pubKeyHash = pubKey.Hash; Console.WriteLine("pub key hash => {0}", pubKeyHash); Console.WriteLine("script pubkey => {0}", pubKey.ScriptPubKey); BitcoinAddress addr = new BitcoinPubKeyAddress(pubKeyHash, Network.RegTest); Console.WriteLine("p2pkh address @regtest => {0}", addr); Console.WriteLine("p2pkh address @regtest => {0}", pubKey.GetAddress(Network.RegTest)); Console.WriteLine("p2pkh address @testnet => {0}", pubKey.GetAddress(Network.TestNet)); Console.WriteLine("p2pkh address @main => {0}", pubKey.GetAddress(Network.Main)); Console.ReadLine(); } }}
四、身份验证逻辑
BitcoinAddress类除了Network属性之外,还有一个属性ScriptPubKey值得我们研究:
ScriptPubKey属性用来获取地址对应的公钥脚本,它将会返回如下的结果
公钥脚本有什么作用?
让我们先考虑一个相关的问题:如果一个UTXO上标明了接收地址,那么接收地址 的持有人该如何向节点证明这个UTXO属于他?
P2PKH地址是由公钥推导出来的,我们知道公钥可以验证私钥的签名,那么 只要引用UTXO的交易,提供对交易的签名和公钥,节点就可以利用公钥, 来验证提交交易者,是不是该地址的持有人了:
在上图中,交易2222的提交者需要在交易的输入中,为引用的每个UTXO补充 自己的公钥以及交易签名,然后提交给节点。节点将按照如下逻辑验证提交者是否是地址 x的真正持有人:
验证公钥:利用公钥推算地址,检查是否与地址x一致,如果不一致则拒绝交易验证私钥:利用交易和公钥,验证提交的签名是否匹配,如果不一致则拒绝交易接受并广播交易
因此,当我们向目标地址发送比特币时,实际上相当于给这个转出的UTXO 加了一个目标地址提供的锁,而只有目标地址对应的私钥才可以解开这个锁。 回到前面的问题,getScriptPubKey()方法返回的公钥脚本,就对应于这个 提供给发送方的锁了 —— 给我发的UTXO,请用我提供的锁先锁上。
五、P2PKH脚本执行原理
在前一节,我们理解了节点如何验证交易提交者对UTXO的所有权,那么接 下来就容易理解ScriptPub属性获取的脚本到底是什么了。
简单地说,比特币实际上是将UTXO所有权的验证逻辑,从节点中剥离 到交易中实现的:在UTXO中定义一段脚本(公钥脚本),在引用UTXO时定义另一段脚本 (签名脚本),节点在验证UTXO所有权时,只需要拼接这两段脚本,并确定运行结果为 真,就表示交易提交者的确持有该UTXO:
比特币所采用的脚本采用自定义的简单语法,不支持循环,因此不是图灵 完备的语言,但也降低了安全风险。脚本使用预定义的指令编写,从左至右依次执行。
例如,对于P2PKH地址,其对应的采用助记符表示的两部分脚本如下:
scriptPubKey: OP_DUP OP_HASH160
最终节点合并脚本时,总会将scriptPubKey放在后面,而scriptSig放在前面:
比特币脚本指令的运行需要一个栈,在上图中,列出了整个脚本的7个指令执行 过程中,每个指令执行后的栈的情况。接下来让我们单步跟踪指令的运行情况。
签名与公钥入栈
指令1和2首先将签名和公钥压入栈。 当执行第1个指令时,将向栈顶压入签名
公钥验证
接下来指令3/4/5/6将验证公钥是否匹配scriptPubKey中预留的解锁公钥哈希。
首先,使用指令OP_DUP将栈顶成员复制一份再压入栈,因此该指令执行后,栈顶将有 两个公钥
接下来,使用指令OP_HASH160提取栈顶成员并进行两重哈希计算(SHA-256 -> RIMPEMD-160), 我们知道这就是公钥哈希的算法。该指令的结果将压入栈,以便和scriptPubKey中 的预留公钥哈希进行对比。
然后,脚本会将scriptPubKey中预留的解锁公钥哈希压入栈顶,这样栈顶就有两个公钥哈希了: 预留的解锁公钥哈希,以及根据解锁脚本提供的公钥重新生成的公钥哈希。
指令OP_EQUALVERIFY将提取栈顶的两个公钥哈希进行比较,如果不相等则直接标注交易无效, 退出脚本执行。如果成功的话,栈顶此时只有两个成员了:公钥和交易签名。
签名验证
指令OP_CHECKSIG负责提取栈顶的两个成员进行签名验证,如果验证成功,则将01压入栈, 栈顶的非零值意味着验证成功。否则将00压入栈,这意味着验证失败。
六、创建P2SH地址
基于前一节的学习,我们知道比特币的UTXO所有权的认证,是完全基于 交易中嵌入的两部分脚本来完成的,这种独立于节点旳脚本化验证机制为比特币 的支付提供了巨大的灵活性。
P2SH(Pay To Script Hash)地址就是为了充分利用比特币的脚本能力而提出的改进。 容易理解,这种地址是基于脚本的哈希来构造的 —— 该脚本被称为赎回(redeem)脚本:
P2SH地址的公钥脚本只是简单地验证UTXO的消费者所提交的序列化的赎回脚本serializedRedeemScript是否匹配预留的脚本哈希scriptHash:
如果上述验证通过,那么节点会将序列化的赎回脚本展开并与签名再次拼接。例如 下图展示了一个简单的赎回脚本展开后与签名拼接的完整脚本:
同样,P2SH地址前缀根据网络不同有所区别:
脚本
NBitcoin实现了完整的比特币脚本编写与执行功能,使用ScriptBuilder 类提供的方便函数来构造脚本对象:
基于P2SH地址的构造原理,我们可以创建任意一个脚本作为赎回脚本来创建一个P2SH 地址。例如,在下面的代码中首先生成前面描述的简单赎回脚本,然后创建该脚本的P2SH地址:
Key key = new Key();Script redeemScript = newScript(Op.GetPushOp(key.PubKey.ToBytes()),OpcodeType.OP_CHECKSIG);BitcoinAddress addr = new BitcoinScriptAddress(redeemScript.Hash,Network.RegTest);Console.WriteLine("p2sh address => {0}",addr);using NBitcoin;using System;namespace Newp2sh{ class Program { static void Main(string[] args) { Key key = new Key(); Script redeemScript = newScript( Op.GetPushOp(key.PubKey.ToBytes()), OpcodeType.OP_CHECKSIG); BitcoinAddress addr = new BitcoinScriptAddress(redeemScript.Hash, Network.RegTest); Console.WriteLine("p2sh addr@regtest => {0}", addr); Console.WriteLine("p2sh addr@regtest => {0}", redeemScript.Hash.GetAddress(Network.RegTest)); Console.ReadLine(); } }}
七、多重签名赎回脚本
P2SH地址应用最多的领域就是进行多重签名交易:一个UTXO的消费交易必须从n个 参与者中至少获得m个签名才能被确认,这被称为m-of-n签名。
例如,一个2-of-3的多重签名的赎回脚本如下:
多重签名的赎回脚本主要使用指令OP_CHECKMULTISIG完成,它执行时需要栈顶 的成员如下
我们可以使用Script类从头创建多重签名赎回脚本,但更简单的是使用 PayToMultiSigTemplate类直接返回赎回脚本:
当获得赎回脚本后,使用赎回脚本的Hash属性值,结合对应的Network 实例,就可以获得这个赎回脚本对应的P2SH地址了。 例如,下面的代码构造一个2-of-2签名脚本,并创建其对应的P2SH地址:
Key keyTommy = new Key();Key keyJerry = new Key();var generator = PayToMultiSigTemplate.Instance;Script redeemScript = generator.GenerateScriptPubKey(2,keyTommy.PubKey,keyJerry.PubKey);BitcoinAddress addr = new BitcoinScriptAddress(redeemScript.Hash,Network.RegTest);Console.WriteLine("p2sh address@regtest => {0}",addr);using NBitcoin;using System;using System.Linq;namespace Newp2shmsig{ class Program { static void Main(string[] args) { Key[] keys = new[] { new Key(), new Key(), new Key() }; PubKey[] pubKeys = keys.Select(key => key.PubKey).ToArray(); for (var i = 0; i < pubKeys.Count(); i++) { Console.WriteLine("pubkey#{0} => {1}", i, pubKeys[i]); } Script redeem = PayToMultiSigTemplate.Instance.GenerateScriptPubKey(2, pubKeys); Console.WriteLine("msig script => {0}", redeem); BitcoinAddress addr = new BitcoinScriptAddress(redeem.Hash, Network.RegTest); Console.WriteLine("msig p2sh address @regtest => {0}", addr); Console.ReadLine(); } }}