比特幣入門之地址的離線生成與管理

一、概述

在前面的課程中,我們使用節點軟件的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

 

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