一、概述
在前面的課程中,我們使用節點軟件的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(); } } }