比特币入门之地址的离线生成与管理

一、概述

在前面的课程中,我们使用节点软件的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();
        }
    }
}
View Code

 三、创建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();
        }
    }
}
View Code

四、身份验证逻辑

BitcoinAddress类除了Network属性之外,还有一个属性ScriptPubKey值得我们研究:

ScriptPubKey属性用来获取地址对应的公钥脚本,它将会返回如下的结果

公钥脚本有什么作用?

让我们先考虑一个相关的问题:如果一个UTXO上标明了接收地址,那么接收地址 的持有人该如何向节点证明这个UTXO属于他?

P2PKH地址是由公钥推导出来的,我们知道公钥可以验证私钥的签名,那么 只要引用UTXO的交易,提供对交易的签名和公钥,节点就可以利用公钥, 来验证提交交易者,是不是该地址的持有人了:

在上图中,交易2222的提交者需要在交易的输入中,为引用的每个UTXO补充 自己的公钥以及交易签名,然后提交给节点。节点将按照如下逻辑验证提交者是否是地址 x的真正持有人:

  1. 验证公钥:利用公钥推算地址,检查是否与地址x一致,如果不一致则拒绝交易
  2. 验证私钥:利用交易和公钥,验证提交的签名是否匹配,如果不一致则拒绝交易
  3. 接受并广播交易

因此,当我们向目标地址发送比特币时,实际上相当于给这个转出的UTXO 加了一个目标地址提供的锁,而只有目标地址对应的私钥才可以解开这个锁。 回到前面的问题,getScriptPubKey()方法返回的公钥脚本,就对应于这个 提供给发送方的锁了 —— 给我发的UTXO,请用我提供的锁先锁上。

五、P2PKH脚本执行原理

在前一节,我们理解了节点如何验证交易提交者对UTXO的所有权,那么接 下来就容易理解ScriptPub属性获取的脚本到底是什么了。

简单地说,比特币实际上是将UTXO所有权的验证逻辑,从节点中剥离 到交易中实现的:在UTXO中定义一段脚本(公钥脚本),在引用UTXO时定义另一段脚本 (签名脚本),节点在验证UTXO所有权时,只需要拼接这两段脚本,并确定运行结果为 真,就表示交易提交者的确持有该UTXO:

比特币所采用的脚本采用自定义的简单语法,不支持循环,因此不是图灵 完备的语言,但也降低了安全风险。脚本使用预定义的指令编写,从左至右依次执行。

例如,对于P2PKH地址,其对应的采用助记符表示的两部分脚本如下:

scriptPubKey: OP_DUP OP_HASH160 <pubKeyHash> OP_EQUALVERIFY OP_CHECKSIG
scriptSig: <sig> <pubKey>

最终节点合并脚本时,总会将scriptPubKey放在后面,而scriptSig放在前面:

比特币脚本指令的运行需要一个栈,在上图中,列出了整个脚本的7个指令执行 过程中,每个指令执行后的栈的情况。接下来让我们单步跟踪指令的运行情况。

签名与公钥入栈

指令1和2首先将签名和公钥压入栈。 当执行第1个指令时,将向栈顶压入签名<sig>,当执行第2个指令时,将向栈顶压入 公钥<pubkey>

公钥验证

接下来指令3/4/5/6将验证公钥是否匹配scriptPubKey中预留的解锁公钥哈希。

首先,使用指令OP_DUP将栈顶成员复制一份再压入栈,因此该指令执行后,栈顶将有 两个公钥<pubkey>

接下来,使用指令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 = new Script(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 = new Script(
                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();

        }
    }
}
View Code

七、多重签名赎回脚本

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();
        }
    }
}
View Code

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章