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

社會主義接班人發表於2019-06-18

一、概述

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

 

相關文章