一、概述
在前面的課程中,我們使用節點軟體的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 <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(); } } }
七、多重簽名贖回指令碼
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(); } } }