基於Java語言構建區塊鏈(五)—— 地址(錢包)

wangwei_hz發表於2018-03-25

wallet

最終內容請以原文為準:https://wangwei.one/posts/f9088e0f.html

引言

上一篇 文章當中,我們開始了交易機制的實現。你已經瞭解到交易的一些非個人特徵:沒有使用者賬戶,您的個人資料(例如:姓名、護照號碼以及SSN(美國社會安全卡(Social Security Card)上的9 位數字))不是必需的,並且不儲存在比特幣的任何地方。但仍然必須有一些東西能夠識別你是這些交易輸出的所有者(例如:鎖定在這些輸出上的幣的所有者)。這就是比特幣地址的作用所在。到目前為止,我們只是使用了任意的使用者定義的字串當做地址,現在是時候來實現真正的地址了,就像它們在比特幣中實現的一樣。

比特幣地址

這裡有一個比特幣地址的示例:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa。這是一個非常早期的比特幣地址,據稱是屬於中本聰的比特幣地址。比特幣地址是公開的。如果你想要給某人傳送比特幣,你需要知道對方的比特幣地址。但是地址(儘管它是唯一的)並不能作為你是一個錢包所有者的憑證。事實上,這樣的地址是公鑰的一種可讀性更好的表示 。在比特幣中,你的身份是儲存在你計算機上(或儲存在你有權訪問的其他位置)的一對(或多對)私鑰和公鑰。比特幣依靠加密演算法的組合來建立這些金鑰,並保證世界上沒有其他人任何人可以在沒有物理訪問金鑰的情況下訪問您的比特幣。

比特幣地址與公鑰不同。比特幣地址是由公鑰經過單向的雜湊函式生成的

PubKey to bitcoin address

接下來,讓我們來討論一下這些加密演算法。

注意:不要向本篇文章中的程式碼所生成的任何比特幣地址傳送真實的比特幣來進行測試,否則後果自負……

公鑰密碼學

公鑰加密演算法(public-key cryptography)使用的是金鑰對:公鑰和私鑰。公鑰屬於非敏感資訊,可以向任何人透露。相比之下,私鑰不能公開披露:除了所有者之外,任何人都不能擁有私鑰的許可權,因為它是用作所有者標識的私鑰。你的私鑰代表就是你(當然是在加密貨幣世界裡的)。

本質上,比特幣錢包就是一對這樣的金鑰。當你安裝一個錢包應用程式或者使用比特幣客戶端去生成一個新的地址時,它們就為你建立好了一個金鑰對。在比特幣種,誰控制了私鑰,誰就掌握了所有發往對應公鑰地址上所有比特幣的控制權。

私鑰和公鑰只是隨機的位元組序列,因此它們不能被列印在螢幕上供人讀取。這就是為什麼比特幣會用一種演算法將公鑰的位元組序列轉化為人類可讀的字串形式。

如果你曾今使用過比特幣錢包的應用程式,它可能會為你生成助記詞密碼短語。這些助記詞可以用來替代私鑰,並且能夠生成私鑰。這種機制是通過 BIP-039 來實現的。

好了,現在我們已經知道在比特幣中由什麼來決定使用者的標識了。但是,比特幣是如何校驗交易輸出(和它裡面儲存的一些幣)的所有權的呢?

數字簽名

在數學和密碼學中,有個數字簽名的概念,這套演算法保證了以下幾點:

  1. 保證資料從傳送端傳遞到接收端的過程中不會被篡改;
  2. 資料由某個傳送者建立;
  3. 傳送者不能否認傳送的資料;

通過對資料應用簽名演算法(即簽署資料),可以得到一個簽名,以後可以對其進行驗證。數字簽名需要使用私鑰,而驗證則需要公鑰。

為了能夠簽署資料我們需要:

  1. 用於被簽名的資料;
  2. 私鑰。

簽名操作會產生一個儲存在交易輸入中的簽名。為了能夠驗證一個簽名,我們需要:

  1. 簽名之後的資料;
  2. 簽名;
  3. 公鑰。

簡單來講,這個驗證的過程可以被描述為:檢查簽名是由被簽名資料加上私鑰得來,並且這個公鑰也是由該私鑰生成。

數字簽名並不是一種加密方法,你無法從簽名反向構造出源資料。這個和我們 前面 提到過的Hash演算法有點類似:通過對一個資料使用Hash演算法,你可以得到該資料的唯一表示。它們兩者的不同之處在於,簽名演算法多了一個金鑰對:它讓數字簽名得以驗證成為可能。

但是金鑰對也能夠用於去加密資料:私鑰用於加密資料,公鑰用於解密資料。不過比特幣並沒有使用加密演算法。

在比特幣中,每一筆交易輸入都會被該筆交易的建立者進行簽名。比特幣中的每一筆交易在放入區塊之前都必須得到驗證。驗證的意思就是:

  • 檢查交易輸入是否擁有引用前一筆交易中交易輸出的許可權
  • 檢查交易的簽名是否正確

資料簽名以及簽名驗證的過程如下圖所示:

signing-scheme

讓我們來回顧一下交易的完整生命週期:

  1. 最開始,會有一個包含了Coinbase交易的創世區塊。由於在Coinbase交易中沒有真正的交易輸入,所以它不需要簽名。Coinbase交易的交易輸出會包含一個Hashing之後的公鑰(使用的演算法為 RIPEMD16(SHA256(PubKey))
  2. 當一個人傳送比特幣時,會建立一筆交易。這筆交易的交易輸入會引用前一筆或多筆交易的交易輸出。每一個交易輸入將會儲存未經Hashing處理的公鑰以及整個交易的簽名資訊。
  3. 當比特幣網路中的其他節點收到其他節點廣播的交易資料之後將,將會對其進行驗證。其他的事情除外,他們將會驗證:
    • 檢查交易輸入中公鑰的Hash值是否與它所引用的交易輸出的Hash值想匹配,這是確保傳送方只能傳送屬於他們自己的比特幣。
    • 檢查簽名是否正確,這是為了確保這筆交易是由比特幣的真正所有者建立的。
  4. 當一個礦工準備開始開採一個新的區塊時,他會將交易資訊放入區塊中,然後開始挖礦。
  5. 當一個區塊完成挖礦之後,網路中的其他節點將會收到一條區塊已挖礦完畢的訊息,並且他們會把這個區塊新增到區塊鏈中去。
  6. 當一個區塊被新增到區塊鏈之後,就標誌著這筆交易已經完成,它所產生的交易輸出將會在新的交易中被引用。

橢圓曲線密碼學

正如前面所提到的那樣,公鑰和私鑰是一串隨機的字元序列。由於私鑰是用來識別比特幣所有者身份的緣故,因此有一個必要的條件:這個隨機演算法必須產生真正的隨機序列。我們不希望意外地生成其他人所擁有的私鑰。也就是要保證隨機序列的絕對唯一性。

比特幣是使用的橢圓曲線來生成的私鑰。橢圓曲線是一個非常複雜的數學概念,這裡我們不做詳細的介紹(如果你對此非常好奇,可以點選 this gentle introduction to elliptic curves 進行詳細的 瞭解,警告:數學公式)。我們需要知道的是,這些曲線可以用來生成真正大而隨機的數字。比特幣所採用的曲線演算法能夠隨機生成一個介於0到 2^2^56之間的數字(這是一個非常大的數字,用十進位制表示的話,大約是10^77, 而整個可見的宇宙中,原子數在 10^78 到 10^82 之間) 。這麼巨大的上限意味著產生兩個一樣的私鑰是幾乎不可能的事情。

另外,我們將會使用比特幣中所使用的 ECDSA (橢圓曲線數字簽名演算法)去簽署交易資訊。

Base58和Base58Check編碼

現在讓我們回到上面提到的比特幣地址:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa . 現在我們知道這個地址其實是公鑰的一種可讀高的表示方式。如果我們對他進行解碼,我們會看到公鑰看起來是這樣子的(位元組序列的十六進位制的表示方式):

0062E907B15CBF27D5425399EBF6F0FB50EBB88F18C29B7D93
複製程式碼

Base58

Base64使用了26個小寫字母、26個大寫字母、10個數字以及兩個符號(例如“+”和“/”),用於在電子郵件這樣的基於文字的媒介中傳輸二進位制資料。Base64通常用於編碼郵件中的附件。Base58是一種基於文字的二進位制編碼格式,用在比特幣和其它的加密貨幣中。這種編碼格式不僅實現了資料壓縮,保持了易讀性,還具有錯誤診斷功能。Base58是Base64編碼格式的子集,同樣使用大小寫字母和10個數字,但捨棄了一些容易錯讀和在特定字型中容易混淆的字元。具體地,Base58不含Base64中的0(數字0)、O(大寫字母o)、l(小寫字母L)、I(大寫字母i),以及“+”和“/”兩個字元。簡而言之,Base58就是由不包括(0,O,l,I)的大小寫字母和數字組成。

比特幣的Base58字母表:

123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz

Base58Check

Base58Check是一種常用在比特幣中的Base58編碼格式,增加了錯誤校驗碼來檢查資料在轉錄中出現的錯誤。校驗碼長4個位元組,新增到需要編碼的資料之後。校驗碼是從需要編碼的資料的雜湊值中得到的,所以可以用來檢測並避免轉錄和輸入中產生的錯誤。使用Base58check編碼格式時,編碼軟體會計算原始資料的校驗碼並和結果資料中自帶的校驗碼進行對比。二者不匹配則表明有錯誤產生,那麼這個Base58Check格式的資料就是無效的。例如,一個錯誤比特幣地址就不會被錢包認為是有效的地址,否則這種錯誤會造成資金的丟失。

為了使用Base58Check編碼格式對資料(數字)進行編碼,首先我們要對資料新增一個稱作“版本位元組”的字首,這個字首用來明確需要編碼的資料的型別。例如,比特幣地址的字首是0(十六進位制是0x00),而對私鑰編碼時字首是128(十六進位制是0x80)。

讓我們以示意圖的形式展示一下從公鑰得到地址的過程:

Base58Check Encoding

因此,上述解碼的公鑰由三部分組成:

Version  Public key hash                           Checksum
00       62E907B15CBF27D5425399EBF6F0FB50EBB88F18  C29B7D93
複製程式碼

由於雜湊函式是單向的(也就說無法逆轉回去),所以不可能從一個雜湊中提取公鑰。不過通過執行雜湊函式並進行雜湊比較,我們可以檢查一個公鑰是否被用於雜湊的生成。

OK,現在我們有了所有的東西,讓我們來編寫一些程式碼。 當一些概念被寫成程式碼時,我們會對此理解的更加清晰和深刻。

地址實現

讓我們從 Wallet 的構成開始,這裡我們需要先引入一個maven包:

<dependency>
    <groupId>org.bouncycastle</groupId>
    <artifactId>bcprov-jdk15on</artifactId>
    <version>1.59</version>
</dependency>
複製程式碼

錢包結構

/**
 * 錢包
 *
 * @author wangwei
 * @date 2018/03/14
 */
@Data
@AllArgsConstructor
public class Wallet {

    // 校驗碼長度
    private static final int ADDRESS_CHECKSUM_LEN = 4;
    /**
     * 私鑰
     */
    private BCECPrivateKey privateKey;
    /**
     * 公鑰
     */
    private byte[] publicKey;

    public Wallet() {
        initWallet();
    }

    /**
     * 初始化錢包
     */
    private void initWallet() {
        try {
            KeyPair keyPair = newECKeyPair();
            BCECPrivateKey privateKey = (BCECPrivateKey) keyPair.getPrivate();
            BCECPublicKey publicKey = (BCECPublicKey) keyPair.getPublic();

            byte[] publicKeyBytes = publicKey.getQ().getEncoded(false);

            this.setPrivateKey(privateKey);
            this.setPublicKey(publicKeyBytes);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 建立新的金鑰對
     *
     * @return
     * @throws Exception
     */
    private KeyPair newKeyPair() throws Exception {
        // 註冊 BC Provider
        Security.addProvider(new BouncyCastleProvider());
        // 建立橢圓曲線演算法的金鑰對生成器,演算法為 ECDSA
        KeyPairGenerator g = KeyPairGenerator.getInstance("ECDSA", BouncyCastleProvider.PROVIDER_NAME);
        // 橢圓曲線(EC)域引數設定
        // bitcoin 為什麼會選擇 secp256k1,詳見:https://bitcointalk.org/index.php?topic=151120.0
        ECParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1");
        g.initialize(ecSpec, new SecureRandom());
        return g.generateKeyPair();
    }
 
}    
複製程式碼

所謂的錢包,其實本質上就是一個金鑰對。這裡我們需要藉助 KeyPairGenerator 生成金鑰對。

接著,我們來生成比特幣的錢包地址:

public class Wallet {
    
    ...
   
    /**
     * 獲取錢包地址
     *
     * @return
     */
    public String getAddress() throws Exception {
        // 1. 獲取 ripemdHashedKey
        byte[] ripemdHashedKey = BtcAddressUtils.ripeMD160Hash(this.getPublicKey().getEncoded());

        // 2. 新增版本 0x00
        ByteArrayOutputStream addrStream = new ByteArrayOutputStream();
        addrStream.write((byte) 0);
        addrStream.write(ripemdHashedKey);
        byte[] versionedPayload = addrStream.toByteArray();

        // 3. 計算校驗碼
        byte[] checksum = BtcAddressUtils.checksum(versionedPayload);

        // 4. 得到 version + paylod + checksum 的組合
        addrStream.write(checksum);
        byte[] binaryAddress = addrStream.toByteArray();

        // 5. 執行Base58轉換處理
        return Base58Check.rawBytesToBase58(binaryAddress);
    }
	
    ...
}

複製程式碼

這個時候,你就可以得到 真實的比特幣地址 了,並且你可以到 blockchain.info 上去檢查這個地址的餘額。

例如,通過 getAddress 方法,得到了一個比特幣地址為:1rZ9SjXMRwnbW3Pu8itC1HtNBVHERSQhaACbL16

我敢保證,無論你生成多少次比特幣地址,它的餘額始終為0.這就是為什麼選擇適當的公鑰密碼演算法如此重要:考慮到私鑰是隨機數字,產生相同數字的機會必須儘可能低。 理想情況下,它必須低至“永不”。

另外,需要注意的是你不需要連線到比特幣的節點上去獲取比特幣的地址。有關地址生成的開源演算法工具包已經有很多程式語言和庫實現了。

現在,我們需要去修改交易輸入與輸出,讓他們開始使用真實的地址:

交易輸入

/**
 * 交易輸入
 *
 * @author wangwei
 * @date 2017/03/04
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class TXInput {

    /**
     * 交易Id的hash值
     */
    private byte[] txId;
    /**
     * 交易輸出索引
     */
    private int txOutputIndex;
    /**
     * 簽名
     */
    private byte[] signature;
    /**
     * 公鑰
     */
    private byte[] pubKey;


    /**
     * 檢查公鑰hash是否用於交易輸入
     *
     * @param pubKeyHash
     * @return
     */
    public boolean usesKey(byte[] pubKeyHash) {
        byte[] lockingHash = BtcAddressUtils.ripeMD160Hash(this.getPubKey());
        return Arrays.equals(lockingHash, pubKeyHash);
    }

}
複製程式碼

交易輸出

/**
 * 交易輸出
 *
 * @author wangwei
 * @date 2017/03/04
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class TXOutput {

    /**
     * 數值
     */
    private int value;
    /**
     * 公鑰Hash
     */
    private byte[] pubKeyHash;

    /**
     * 建立交易輸出
     *
     * @param value
     * @param address
     * @return
     */
    public static TXOutput newTXOutput(int value, String address) {
        // 反向轉化為 byte 陣列
        byte[] versionedPayload = Base58Check.base58ToBytes(address);
        byte[] pubKeyHash = Arrays.copyOfRange(versionedPayload, 1, versionedPayload.length);
        return new TXOutput(value, pubKeyHash);
    }

    /**
     * 檢查交易輸出是否能夠使用指定的公鑰
     *
     * @param pubKeyHash
     * @return
     */
    public boolean isLockedWithKey(byte[] pubKeyHash) {
        return Arrays.equals(this.getPubKeyHash(), pubKeyHash);
    }

}

複製程式碼

程式碼中還有很多其他的地方需要變動,這裡不一一指出,詳見文末的原始碼連線。

注意,由於我們不會去實現指令碼語言特性,所以我們不再使用 scriptPubKeyscriptSig 欄位。取而代之的是,我們將 scriptSig 拆分為了 signaturepubKey 欄位,scriptPubKey 重新命名為了 pubKeyHash 。我們將會實現類似於比特幣中的交易輸出鎖定/解鎖邏輯和交易輸入的簽名邏輯,但是我們會在方法中執行此操作。

usesKey 用於檢查交易輸入中的公鑰是否能夠解鎖交易輸出。需要注意的是,交易輸入中儲存的是未經hash過的公鑰,但是方法實現中對它做了一步 ripeMD160Hash 轉化。

isLockedWithKey 用於檢查提供的公鑰Hash是否能夠用於解鎖交易輸出,這個方法是 usesKey 的補充。usesKey 被用於 getAllSpentTXOs 方法中,isLockedWithKey 被用於 findUnspentTransactions 方法中,這樣使得在前後兩筆交易之間建立起了連線。

newTXOutput 方法中,將 value 鎖定到了 address 上。當我們向別人傳送比特幣時,我們只知道他們的地址,因此函式將地址作為唯一的引數。然後解碼地址,並從中提取公鑰雜湊並儲存在PubKeyHash欄位中。

現在,讓我們一起來檢查一下是否能夠正常執行:

$ java -jar blockchain-java-jar-with-dependencies.jar  createwallet
wallet address : 13dJAkeMyjjXvWCmhsXpDqnszHvhFSLVdh

$ java -jar blockchain-java-jar-with-dependencies.jar  createwallet
wallet address : 1BCY5gCXUMiFYc5ieBMfEUaZn3GYkvVZ2e

$ java -jar blockchain-java-jar-with-dependencies.jar  createwallet
wallet address : 19aomsC58CQ1tPzNLx7kV9yjk1pqZtSzL1

$ java -jar blockchain-java-jar-with-dependencies.jar  createblockchain -address 13dJAkeMyjjXvWCmhsXpDqnszHvhFSLVdh

Elapsed Time: 6.77 seconds 
correct hash Hex: 00000e44be0c94c39a4fef24c67d85c428e8bfbd227e292d75c0f4d398e2e81c 

Done ! 

$ java -jar blockchain-java-jar-with-dependencies.jar  getbalance -address 13dJAkeMyjjXvWCmhsXpDqnszHvhFSLVdh
Balance of '13dJAkeMyjjXvWCmhsXpDqnszHvhFSLVdh': 10

$ java -jar blockchain-java-jar-with-dependencies.jar  send -from 1BCY5gCXUMiFYc5ieBMfEUaZn3GYkvVZ2e -to  13dJAkeMyjjXvWCmhsXpDqnszHvhFSLVd -amount 5
java.lang.Exception: ERROR: Not enough funds

$ java -jar blockchain-java-jar-with-dependencies.jar  send -from 13dJAkeMyjjXvWCmhsXpDqnszHvhFSLVdh -to 1BCY5gCXUMiFYc5ieBMfEUaZn3GYkvVZ2e-amount 5
Elapsed Time: 4.477 seconds 
correct hash Hex: 00000da41dfacc8032a553ed5b1aa5e24318d5d89ca14a16c4f70129609c8365 

Success!

$ java -jar blockchain-java-jar-with-dependencies.jar  getbalance -address 13dJAkeMyjjXvWCmhsXpDqnszHvhFSLVdh
Balance of '13dJAkeMyjjXvWCmhsXpDqnszHvhFSLVdh': 5

$ java -jar blockchain-java-jar-with-dependencies.jar  getbalance -address 1BCY5gCXUMiFYc5ieBMfEUaZn3GYkvVZ2e
Balance of '1BCY5gCXUMiFYc5ieBMfEUaZn3GYkvVZ2e': 5

$ java -jar blockchain-java-jar-with-dependencies.jar  getbalance -address 19aomsC58CQ1tPzNLx7kV9yjk1pqZtSzL1
Balance of '19aomsC58CQ1tPzNLx7kV9yjk1pqZtSzL1': 0
複製程式碼

Nice! 現在讓我們一起來實現交易簽名部分的內容。

簽名實現

交易資料必須被簽名,因為這是比特幣中能夠保證不能花費屬於他人比特幣的唯一方法。如果一個簽名是無效的,那麼這筆交易也是無效的,這樣的話,這筆交易就不能被新增到區塊鏈中去。

我們已經有了實現交易簽名的所有片段,還有一個事情除外:用於簽名的資料。交易資料中哪一部分是真正用於簽名的呢?難道是全部資料?選擇用於簽名的資料相當的重要。用於簽名的資料必須包含以獨特且唯一的方式標識資料的資訊。例如,僅對交易輸出簽名是沒有意義的,因為此簽名不會考慮傳送發與接收方。

考慮到交易資料要解鎖前面的交易輸出,重新分配交易輸出中的 value 值,並且鎖定新的交易輸出,因此下面這些資料是必須被簽名的:

  1. 儲存在解鎖了的交易輸出中的公鑰Hash。它標識了交易的傳送方。
  2. 儲存在新的、鎖定的交易輸出中的公鑰Hash。它標識了交易的接收方。
  3. 新的交易輸出中包含的 value 值。

在比特幣中,鎖定/解鎖邏輯儲存在指令碼中,解鎖指令碼儲存在交易輸入的 ScriptSig 欄位中,而鎖定指令碼儲存在交易輸出的 ScriptPubKey 的欄位中。 由於比特幣允許不同型別的指令碼,因此它會對ScriptPubKey的全部內容進行簽名。

如你所見,我們不需要去對儲存在交易輸入中的公鑰進行簽名。正因為如此,在比特幣中,所簽名的並不是一個交易,而是一個去除部分內容的交易輸入副本,交易輸入裡面儲存了被引用交易輸出的 ScriptPubKey

獲取修剪後的交易副本的詳細過程在這裡. 雖然它可能已經過時了,但是我並沒有找到另一個更可靠的來源。

OK,它看起來有點複雜,因此讓我們來開始coding吧。我們將從 Sign 方法開始:

public class Transaction {

   ...

   /**
     * 簽名
     *
     * @param privateKey 私鑰
     * @param prevTxMap  前面多筆交易集合
     */
    public void sign(BCECPrivateKey privateKey, Map<String, Transaction> prevTxMap) throws Exception {
        // coinbase 交易資訊不需要簽名,因為它不存在交易輸入資訊
        if (this.isCoinbase()) {
            return;
        }
        // 再次驗證一下交易資訊中的交易輸入是否正確,也就是能否查詢對應的交易資料
        for (TXInput txInput : this.getInputs()) {
            if (prevTxMap.get(Hex.encodeHexString(txInput.getTxId())) == null) {
                throw new Exception("ERROR: Previous transaction is not correct");
            }
        }

        // 建立用於簽名的交易資訊的副本
        Transaction txCopy = this.trimmedCopy();
      
        Security.addProvider(new BouncyCastleProvider());
        Signature ecdsaSign = Signature.getInstance("SHA256withECDSA", BouncyCastleProvider.PROVIDER_NAME);
        ecdsaSign.initSign(privateKey);

        for (int i = 0; i < txCopy.getInputs().length; i++) {
            TXInput txInputCopy = txCopy.getInputs()[i];
            // 獲取交易輸入TxID對應的交易資料
            Transaction prevTx = prevTxMap.get(Hex.encodeHexString(txInputCopy.getTxId()));
            // 獲取交易輸入所對應的上一筆交易中的交易輸出
            TXOutput prevTxOutput = prevTx.getOutputs()[txInputCopy.getTxOutputIndex()];
            txInputCopy.setPubKey(prevTxOutput.getPubKeyHash());
            txInputCopy.setSignature(null);
            // 得到要簽名的資料,即交易ID
            txCopy.setTxId(txCopy.hash());
            txInputCopy.setPubKey(null);

            // 對整個交易資訊僅進行簽名,即對交易ID進行簽名
            ecdsaSign.update(txCopy.getTxId());
            byte[] signature = ecdsaSign.sign();

            // 將整個交易資料的簽名賦值給交易輸入,因為交易輸入需要包含整個交易資訊的簽名
            // 注意是將得到的簽名賦值給原交易資訊中的交易輸入
            this.getInputs()[i].setSignature(signature);
        }
    }

	...
	
}	
複製程式碼

這個方法需要私鑰和前面多筆交易集合作為引數。正如前面所提到的那樣,為了能夠對交易資訊進行簽名,我們需要能夠訪問到被交易資料中的交易輸入所引用的交易輸出,因此我們需要得到儲存這些交易輸出的交易資訊。

讓我們來一步一步review這個方法:

if (this.isCoinbase()) {
   return;
}
複製程式碼

由於 coinbase 交易資訊不存在交易輸入資訊,因此它不需要簽名,直接return.

Transaction txCopy = this.trimmedCopy();
複製程式碼

建立交易的副本

public class Transaction {

   ...   
   
   /**
     * 建立用於簽名的交易資料副本
     *
     * @return
     */
    public Transaction trimmedCopy() {
        TXInput[] tmpTXInputs = new TXInput[this.getInputs().length];
        for (int i = 0; i < this.getInputs().length; i++) {
            TXInput txInput = this.getInputs()[i];
            tmpTXInputs[i] = new TXInput(txInput.getTxId(), txInput.getTxOutputIndex(), null, null);
        }

        TXOutput[] tmpTXOutputs = new TXOutput[this.getOutputs().length];
        for (int i = 0; i < this.getOutputs().length; i++) {
            TXOutput txOutput = this.getOutputs()[i];
            tmpTXOutputs[i] = new TXOutput(txOutput.getValue(), txOutput.getPubKeyHash());
        }

        return new Transaction(this.getTxId(), tmpTXInputs, tmpTXOutputs);
    }
    
    ...
    
}    
複製程式碼

這個交易資料的副本包含了交易輸入與交易輸出,但是交易輸入的 SignaturePubKey 需要設定為null。

使用私鑰初始化 SHA256withECDSA 簽名演算法:

Security.addProvider(new BouncyCastleProvider());
        Signature ecdsaSign = Signature.getInstance("SHA256withECDSA", BouncyCastleProvider.PROVIDER_NAME);
        ecdsaSign.initSign(privateKey);
複製程式碼

接下來,我們迭代交易副本中的交易輸入:

for (TXInput txInput : txCopy.getInputs()) {
      // 獲取交易輸入TxID對應的交易資料
      Transaction prevTx = prevTxMap.get(Hex.encodeHexString(txInputCopy.getTxId()));
      // 獲取交易輸入所對應的上一筆交易中的交易輸出
      TXOutput prevTxOutput = prevTx.getOutputs()[txInputCopy.getTxOutputIndex()];
      txInputCopy.setPubKey(prevTxOutput.getPubKeyHash());
      txInputCopy.setSignature(null);
複製程式碼

在每一個 txInput中,signature 都需要設定為null(僅僅是為了二次確認檢查),並且 pubKey 設定為它所引用的交易輸出的 pubKeyHash 欄位。在此刻,除了當前的正在迴圈的交易輸入(txInput)外,其他所有的交易輸入都是"空的",也就是說他們的 SignaturePubKey 欄位被設定為 null。因此,交易輸入是被分開簽名的,儘管這對於我們的應用並不十分緊要,但是比特幣允許交易包含引用了不同地址的輸入。

Hash 方法對交易進行序列化,並使用 SHA-256 演算法進行雜湊。雜湊後的結果就是我們要簽名的資料。在獲取完雜湊,我們應該重置 PubKey 欄位,以便於它不會影響後面的迭代。

// 得到要簽名的資料,即交易ID
txCopy.setTxId(txCopy.hash());
txInput.setPubKey(null);
複製程式碼

現在,最關鍵的部分來了:

// 對整個交易資訊僅進行簽名,即對交易ID進行簽名
Security.addProvider(new BouncyCastleProvider());
Signature ecdsaSign = Signature.getInstance("SHA256withECDSA",BouncyCastleProvider.PROVIDER_NAME);
ecdsaSign.initSign(privateKey);
ecdsaSign.update(txCopy.getTxId());
byte[] signature = ecdsaSign.sign();

// 將整個交易資料的簽名賦值給交易輸入,因為交易輸入需要包含整個交易資訊的簽名
// 注意是將得到的簽名賦值給原交易資訊中的交易輸入
this.getInputs()[i].setSignature(signature);
複製程式碼

使用 SHA256withECDSA 簽名演算法加上私鑰,來對交易ID進行簽名,從而得到了交易輸入所要設定的交易簽名。

現在,讓我們來實現交易的驗證功能:

public class Transaction {

    ...

    /**
     * 驗證交易資訊
     *
     * @param prevTxMap 前面多筆交易集合
     * @return
     */
    public boolean verify(Map<String, Transaction> prevTxMap) throws Exception {
        // coinbase 交易資訊不需要簽名,也就無需驗證
        if (this.isCoinbase()) {
            return true;
        }

        // 再次驗證一下交易資訊中的交易輸入是否正確,也就是能否查詢對應的交易資料
        for (TXInput txInput : this.getInputs()) {
            if (prevTxMap.get(Hex.encodeHexString(txInput.getTxId())) == null) {
                throw new Exception("ERROR: Previous transaction is not correct");
            }
        }

        // 建立用於簽名驗證的交易資訊的副本
        Transaction txCopy = this.trimmedCopy();
        
        Security.addProvider(new BouncyCastleProvider());
        ECParameterSpec ecParameters = ECNamedCurveTable.getParameterSpec("secp256k1");
        KeyFactory keyFactory = KeyFactory.getInstance("ECDSA", BouncyCastleProvider.PROVIDER_NAME);
        Signature ecdsaVerify = Signature.getInstance("SHA256withECDSA", BouncyCastleProvider.PROVIDER_NAME);
        
        for (int i = 0; i < this.getInputs().length; i++) {
            TXInput txInput = this.getInputs()[i];
            // 獲取交易輸入TxID對應的交易資料
            Transaction prevTx = prevTxMap.get(Hex.encodeHexString(txInput.getTxId()));
            // 獲取交易輸入所對應的上一筆交易中的交易輸出
            TXOutput prevTxOutput = prevTx.getOutputs()[txInput.getTxOutputIndex()];

            TXInput txInputCopy = txCopy.getInputs()[i];
            txInputCopy.setSignature(null);
            txInputCopy.setPubKey(prevTxOutput.getPubKeyHash());
            // 得到要簽名的資料,即交易ID
            txCopy.setTxId(txCopy.hash());
            txInputCopy.setPubKey(null);
            
            // 使用橢圓曲線 x,y 點去生成公鑰Key
            BigInteger x = new BigInteger(1, Arrays.copyOfRange(txInput.getPubKey(), 1, 33));
            BigInteger y = new BigInteger(1, Arrays.copyOfRange(txInput.getPubKey(), 33, 65));
            ECPoint ecPoint = ecParameters.getCurve().createPoint(x, y);

            ECPublicKeySpec keySpec = new ECPublicKeySpec(ecPoint, ecParameters);
            PublicKey publicKey = keyFactory.generatePublic(keySpec);
            ecdsaVerify.initVerify(publicKey);
            ecdsaVerify.update(txCopy.getTxId());
            if (!ecdsaVerify.verify(txInput.getSignature())) {
                return false;
            }
        }
        return true;
    }
    
    ...
}
複製程式碼

首選,同前面簽名一樣,我們先獲取交易的拷貝資料:

Transaction txCopy = this.trimmedCopy();
複製程式碼

獲取橢圓曲線引數和簽名類:

Security.addProvider(new BouncyCastleProvider());
        ECParameterSpec ecParameters = ECNamedCurveTable.getParameterSpec("secp256k1");
        KeyFactory keyFactory = KeyFactory.getInstance("ECDSA", BouncyCastleProvider.PROVIDER_NAME);
        Signature ecdsaVerify = Signature.getInstance("SHA256withECDSA", BouncyCastleProvider.PROVIDER_NAME);
複製程式碼

接下來,我們來檢查每一個交易輸入的簽名是否正確:

for (int i = 0; i < this.getInputs().length; i++) {
    TXInput txInput = this.getInputs()[i];
    // 獲取交易輸入TxID對應的交易資料
    Transaction prevTx = prevTxMap.get(Hex.encodeHexString(txInput.getTxId()));
    // 獲取交易輸入所對應的上一筆交易中的交易輸出
    TXOutput prevTxOutput = prevTx.getOutputs()[txInput.getTxOutputIndex()];

    TXInput txInputCopy = txCopy.getInputs()[i];
    txInputCopy.setSignature(null);
    txInputCopy.setPubKey(prevTxOutput.getPubKeyHash());
    // 得到要簽名的資料,即交易ID
    txCopy.setTxId(txCopy.hash());
    txInputCopy.setPubKey(null);
}    
複製程式碼

這部分與Sign方法中的相同,因為在驗證過程中我們需要簽署相同的資料。

// 使用橢圓曲線 x,y 點去生成公鑰Key
BigInteger x = new BigInteger(1, Arrays.copyOfRange(txInput.getPubKey(), 1, 33));
BigInteger y = new BigInteger(1, Arrays.copyOfRange(txInput.getPubKey(), 33, 65));
ECPoint ecPoint = ecParameters.getCurve().createPoint(x, y);

ECPublicKeySpec keySpec = new ECPublicKeySpec(ecPoint, ecParameters);
PublicKey publicKey = keyFactory.generatePublic(keySpec);
ecdsaVerify.initVerify(publicKey);
ecdsaVerify.update(txCopy.getTxId());
if (!ecdsaVerify.verify(txInput.getSignature())) {
    return false;
}
複製程式碼

由於交易輸入中儲存的 pubkey ,實際上是橢圓曲線上的一對 x,y 座標,所以我們可以從 pubKey 得到公鑰PublicKey,然後在用公鑰去簽名進行驗證。如果驗證成功,則返回true,否則,返回false。

現在,我們需要一個方法來獲取以前的交易。 由於這需要與區塊鏈互動,我們將使其成為 blockchain 的一種方法:

public class Blockchain {

    ...

    /**
     * 依據交易ID查詢交易資訊
     *
     * @param txId 交易ID
     * @return
     */
    private Transaction findTransaction(byte[] txId) throws Exception {
        for (BlockchainIterator iterator = this.getBlockchainIterator(); iterator.hashNext(); ) {
            Block block = iterator.next();
            for (Transaction tx : block.getTransactions()) {
                if (Arrays.equals(tx.getTxId(), txId)) {
                    return tx;
                }
            }
        }
        throw new Exception("ERROR: Can not found tx by txId ! ");
    }


    /**
     * 進行交易簽名
     *
     * @param tx         交易資料
     * @param privateKey 私鑰
     */
    public void signTransaction(Transaction tx, BCECPrivateKey privateKey) throws Exception {
        // 先來找到這筆新的交易中,交易輸入所引用的前面的多筆交易的資料
        Map<String, Transaction> prevTxMap = new HashMap<>();
        for (TXInput txInput : tx.getInputs()) {
            Transaction prevTx = this.findTransaction(txInput.getTxId());
            prevTxMap.put(Hex.encodeHexString(txInput.getTxId()), prevTx);
        }
        tx.sign(privateKey, prevTxMap);
    }

    /**
     * 交易簽名驗證
     *
     * @param tx
     */
    private boolean verifyTransactions(Transaction tx) throws Exception {
        Map<String, Transaction> prevTx = new HashMap<>();
        for (TXInput txInput : tx.getInputs()) {
            Transaction transaction = this.findTransaction(txInput.getTxId());
            prevTx.put(Hex.encodeHexString(txInput.getTxId()), transaction);
        }
        return tx.verify(prevTx);
    }

}
複製程式碼

現在,我們需要對我們的交易進行真正的簽名和驗證了,交易的簽名發生在 newUTXOTransaction 中:

 public static Transaction newUTXOTransaction(String from, String to, int amount, Blockchain blockchain) throws Exception {
        
    ...

    Transaction newTx = new Transaction(null, txInputs, txOutput);
    newTx.setTxId(newTx.hash());

    // 進行交易簽名
    blockchain.signTransaction(newTx, senderWallet.getPrivateKey());

    return newTx;
}
複製程式碼

交易的驗證發生在一筆交易被放入區塊之前:

public void mineBlock(Transaction[] transactions) throws Exception {
    // 挖礦前,先驗證交易記錄
    for (Transaction tx : transactions) {
        if (!this.verifyTransactions(tx)) {
           throw new Exception("ERROR: Fail to mine block ! Invalid transaction ! ");
        }
    }

    ...
}
複製程式碼

OK,讓我們再一次對整個工程的程式碼做一個測試,測試結果:

$ java -jar blockchain-java-jar-with-dependencies.jar  createwallet
wallet address : 1GTh9Yjh4eH2a69FMX2kvSpnkJAgLdXFD6

$ java -jar blockchain-java-jar-with-dependencies.jar  createwallet
wallet address : 1NnmFCuNnhPZHfXu38wZi8uEb446pDhaGB

$ java -jar blockchain-java-jar-with-dependencies.jar  createwallet
wallet address : 13K6rfHPifjdH4HXN2okpo4uxNRfVCx13f

$ java -jar blockchain-java-jar-with-dependencies.jar  createblockchain -address 1GTh9Yjh4eH2a69FMX2kvSpnkJAgLdXFD6

Elapsed Time: 164.961 seconds 
correct hash Hex: 00000524231ae1832c49957848d2d1871cc35ff4d113c23be1937c6dff5cdf2a 

Done ! 

$ java -jar blockchain-java-jar-with-dependencies.jar  getbalance -address 1GTh9Yjh4eH2a69FMX2kvSpnkJAgLdXFD6
Balance of '1GTh9Yjh4eH2a69FMX2kvSpnkJAgLdXFD6': 10

$ java -jar blockchain-java-jar-with-dependencies.jar  send -from 1NnmFCuNnhPZHfXu38wZi8uEb446pDhaGB -to  13K6rfHPifjdH4HXN2okpo4uxNRfVCx13f -amount 5
java.lang.Exception: ERROR: Not enough funds

$ java -jar blockchain-java-jar-with-dependencies.jar  send -from 1GTh9Yjh4eH2a69FMX2kvSpnkJAgLdXFD6 -to 1NnmFCuNnhPZHfXu38wZi8uEb446pDhaGB -amount 5
Elapsed Time: 54.92 seconds 
correct hash Hex: 00000354f86cde369d4c39d2b3016ac9a74956425f1348b4c26b2cddb98c100b 

Success!


$ java -jar blockchain-java-jar-with-dependencies.jar  getbalance -address 1GTh9Yjh4eH2a69FMX2kvSpnkJAgLdXFD6
Balance of '1GTh9Yjh4eH2a69FMX2kvSpnkJAgLdXFD6': 5

$ java -jar blockchain-java-jar-with-dependencies.jar  getbalance -address 1NnmFCuNnhPZHfXu38wZi8uEb446pDhaGB
Balance of '1NnmFCuNnhPZHfXu38wZi8uEb446pDhaGB': 5

$ java -jar blockchain-java-jar-with-dependencies.jar  getbalance -address 13K6rfHPifjdH4HXN2okpo4uxNRfVCx13f
Balance of '13K6rfHPifjdH4HXN2okpo4uxNRfVCx13f': 0
複製程式碼

Good!沒有任何錯誤!

讓我們註釋掉 NewUTXOTransaction 方法中的一行程式碼,確保未被簽名的交易不能被新增到區塊中:

blockchain.signTransaction(newTx, senderWallet.getPrivateKey());
複製程式碼

測試結果:

java.lang.Exception: Fail to verify transaction ! transaction invalid ! 
	at one.wangwei.blockchain.block.Blockchain.verifyTransactions(Blockchain.java:334)
	at one.wangwei.blockchain.block.Blockchain.mineBlock(Blockchain.java:76)
	at one.wangwei.blockchain.cli.CLI.send(CLI.java:202)
	at one.wangwei.blockchain.cli.CLI.parse(CLI.java:79)
	at one.wangwei.blockchain.BlockchainTest.main(BlockchainTest.java:23)
複製程式碼

總結

這一節,我們學到了:

  1. 使用橢圓曲線加密演算法,如何去建立錢包;
  2. 瞭解到瞭如何去生成比特幣地址;
  3. 如何去對交易資訊進行簽名並對簽名進行驗證;

到目前為止,我們已經實現了比特幣的許多關鍵特性! 我們已經實現了除外網路外的幾乎所有功能,並且在下一篇文章中,我們將繼續完善交易這一環節機制。

資料

相關文章