基於Java語言構建區塊鏈(六)—— 交易(Merkle Tree)

wangwei_hz發表於2018-04-16

基於Java語言構建區塊鏈(六)—— 交易(Merkle Tree)

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

引言

在這一系列文章的最開始部分,我們提到過區塊鏈是一個分散式的資料庫。那時候,我們決定跳過"分散式"這一環節,並且聚焦於"資料儲存"這一環節。到目前為止,我們幾乎實現了區塊鏈的所有組成部分。在本篇文章中,我們將會涉及一些在前面的文章中所忽略的一些機制,並且在下一篇文章中我們將開始研究區塊鏈的分散式特性。

前面各個部分內容:

  1. 基本原型
  2. 工作量證明
  3. 持久化 & 命令列
  4. 交易(UTXO)
  5. 地址(錢包)

UTXO池

持久化 & 命令列 這篇文章中,我們研究了比特幣核心儲存區塊的方式。當中我們提到過與區塊相關的資料儲存在 blocks 這個資料桶中,而交易資料則儲存在 chainstate 這個資料桶中,讓我們來回憶一下,chainstate 資料桶的資料結構:

  • 'c' + 32-byte transaction hash -> unspent transaction output record for that transaction

    某筆交易的UTXO記錄

  • 'B' -> 32-byte block hash: the block hash up to which the database represents the unspent transaction outputs

    資料庫所表示的UTXO的區塊Hash

從那篇文章開始,我們已經實現了比特幣的交易機制,但是我們還沒有用到 chainstate 資料桶去儲存我們的交易輸出。所以,這將是我們現在要去做的事情。

chainstate 不會去儲存交易資料。相反,它儲存的是 UTXO 集,也就是未被花費的交易輸出集合。除此之外,它還儲存了"資料庫所表示的UTXO的區塊Hash",我們這裡先暫且忽略這一點,因為我們還沒有用到區塊高度(這一點我們會在後面的文章進行實現)。

那麼,我們為什麼需要 UTXO 池呢?

一起來看一下我們前面實現的 findUnspentTransactions 方法:

   /**
     * 查詢錢包地址對應的所有未花費的交易
     *
     * @param pubKeyHash 錢包公鑰Hash
     * @return
     */
    private Transaction[] findUnspentTransactions(byte[] pubKeyHash) throws Exception {
        Map<String, int[]> allSpentTXOs = this.getAllSpentTXOs(pubKeyHash);
        Transaction[] unspentTxs = {};

        // 再次遍歷所有區塊中的交易輸出
        for (BlockchainIterator blockchainIterator = this.getBlockchainIterator(); blockchainIterator.hashNext(); ) {
            Block block = blockchainIterator.next();
            for (Transaction transaction : block.getTransactions()) {

                String txId = Hex.encodeHexString(transaction.getTxId());

                int[] spentOutIndexArray = allSpentTXOs.get(txId);

                for (int outIndex = 0; outIndex < transaction.getOutputs().length; outIndex++) {
                    if (spentOutIndexArray != null && ArrayUtils.contains(spentOutIndexArray, outIndex)) {
                        continue;
                    }

                    // 儲存不存在 allSpentTXOs 中的交易
                    if (transaction.getOutputs()[outIndex].isLockedWithKey(pubKeyHash)) {
                        unspentTxs = ArrayUtils.add(unspentTxs, transaction);
                    }
                }
            }
        }
        return unspentTxs;
    }
複製程式碼

該方法是用來查詢錢包地址對應的包含未花費交易輸出的交易資訊。由於交易資訊是儲存在區塊當中,所以我們現有的做法是遍歷區塊鏈中的每個區塊,然後遍歷每個區塊中的交易資訊,再然後遍歷每個交易中的交易輸出,並檢查交易輸出是否被相應的錢包地址所鎖定,效率非常低下。截止2018年3月29號,比特幣中有 515698 個區塊,並且這些資料佔據了140+Gb 的磁碟空間。這也就意味著一個人必須執行全節點(下載所有的區塊資料)才能驗證交易資訊。此外,驗證交易資訊需要遍歷所有的區塊。

針對這個問題的解決辦法是需要有一個儲存了所有UTXOs(未花費交易輸出)的索引,這就是 UTXOs 池所要做的事情:UTXOs池其實是一個快取空間,它所快取的資料需要從構建區塊鏈中所有的交易資料中獲得(通過遍歷所有的區塊鏈,不過這個構建操作只需要執行一次即可),並且它後續還會用於錢包餘額的計算以及新的交易資料的驗證。截止到2017年9月,UTXOs池大約為 2.7Gb。

好了,讓我們來想一下,為了實現 UTXOs 池我們需要做哪些事情。當前,有下列方法被用於查詢交易資訊:

  1. Blockchain.getAllSpentTXOs —— 查詢所有已被花費的交易輸出。它需要遍歷區塊鏈中所有區塊中交易資訊。

  2. Blockchain.findUnspentTransactions —— 查詢包含未被花費的交易輸出的交易資訊。它也需要遍歷區塊鏈中所有區塊中交易資訊。

  3. Blockchain.findSpendableOutputs —— 該方法用於新的交易建立之時。它需要找到足夠多的交易輸出,以滿足所需支付的金額。需要呼叫 Blockchain.findUnspentTransactions 方法。

  4. Blockchain.findUTXO —— 查詢錢包地址所對應的所有未花費交易輸出,然後用於計算錢包餘額。需要呼叫

    Blockchain.findUnspentTransactions 方法。

  5. Blockchain.findTransaction —— 通過交易ID查詢交易資訊。它需要遍歷所有的區塊直到找到交易資訊為止。

如你所見,上面這些方法都需要去遍歷資料庫中的所有區塊。由於UTXOs池只儲存未被花費的交易輸出,而不會儲存所有的交易資訊,因此我們不會對有 Blockchain.findTransaction 進行優化。

那麼,我們需要下列這些方法:

  1. Blockchain.findUTXO —— 通過遍歷所有的區塊來找到所有未被花費的交易輸出.
  2. UTXOSet.reindex —— 呼叫上面 findUTXO 方法,然後將查詢結果儲存在資料庫中。也即需要進行快取的地方。
  3. UTXOSet.findSpendableOutputs —— 與 Blockchain.findSpendableOutputs 類似,區別在於會使用 UTXO 池。
  4. UTXOSet.findUTXO —— 與Blockchain.findUTXO 類似,區別在於會使用 UTXO 池。
  5. Blockchain.findTransaction —— 邏輯保持不變。

這樣,兩個使用最頻繁的方法將從現在開始使用快取!讓我們開始編碼吧!

定義 UTXOSet

@NoArgsConstructor
@AllArgsConstructor
@Slf4j
public class UTXOSet {
    private Blockchain blockchain;
}
複製程式碼

重建 UTXO 池索引:

public class UTXOSet {
 
   ...
 
  /**
    * 重建 UTXO 池索引
    */
    @Synchronized   
    public void reIndex() {
        log.info("Start to reIndex UTXO set !");
        RocksDBUtils.getInstance().cleanChainStateBucket();
        Map<String, TXOutput[]> allUTXOs = blockchain.findAllUTXOs();
        for (Map.Entry<String, TXOutput[]> entry : allUTXOs.entrySet()) {
            RocksDBUtils.getInstance().putUTXOs(entry.getKey(), entry.getValue());
        }
        log.info("ReIndex UTXO set finished ! ");
    }
    
    ...
}    
複製程式碼

此方法用於初始化 UTXOSet。首先,需要清空 chainstate 資料桶,然後查詢所有未被花費的交易輸出,並將它們儲存到 chainstate 資料桶中。

實現 findSpendableOutputs 方法,供 Transation.newUTXOTransaction 呼叫

public class UTXOSet {
 
   ... 
 
   /**
     * 尋找能夠花費的交易
     *
     * @param pubKeyHash 錢包公鑰Hash
     * @param amount     花費金額
     */
    public SpendableOutputResult findSpendableOutputs(byte[] pubKeyHash, int amount) {
        Map<String, int[]> unspentOuts = Maps.newHashMap();
        int accumulated = 0;
        Map<String, byte[]> chainstateBucket = RocksDBUtils.getInstance().getChainstateBucket();
        for (Map.Entry<String, byte[]> entry : chainstateBucket.entrySet()) {
            String txId = entry.getKey();
            TXOutput[] txOutputs = (TXOutput[]) SerializeUtils.deserialize(entry.getValue());

            for (int outId = 0; outId < txOutputs.length; outId++) {
                TXOutput txOutput = txOutputs[outId];
                if (txOutput.isLockedWithKey(pubKeyHash) && accumulated < amount) {
                    accumulated += txOutput.getValue();

                    int[] outIds = unspentOuts.get(txId);
                    if (outIds == null) {
                        outIds = new int[]{outId};
                    } else {
                        outIds = ArrayUtils.add(outIds, outId);
                    }
                    unspentOuts.put(txId, outIds);
                    if (accumulated >= amount) {
                        break;
                    }
                }
            }
        }
        return new SpendableOutputResult(accumulated, unspentOuts);
    }
    
    ...
    
}    
複製程式碼

實現 findUTXOs 介面,供 CLI.getBalance 呼叫:

public class UTXOSet {
 
   ... 
 
   /**
     * 查詢錢包地址對應的所有UTXO
     *
     * @param pubKeyHash 錢包公鑰Hash
     * @return
     */
    public TXOutput[] findUTXOs(byte[] pubKeyHash) {
        TXOutput[] utxos = {};
        Map<String, byte[]> chainstateBucket = RocksDBUtils.getInstance().getChainstateBucket();
        if (chainstateBucket.isEmpty()) {
            return utxos;
        }
        for (byte[] value : chainstateBucket.values()) {
            TXOutput[] txOutputs = (TXOutput[]) SerializeUtils.deserialize(value);
            for (TXOutput txOutput : txOutputs) {
                if (txOutput.isLockedWithKey(pubKeyHash)) {
                    utxos = ArrayUtils.add(utxos, txOutput);
                }
            }
        }
        return utxos;
    }
    
    ...
    
}    
複製程式碼

以上這些方法都是先前 Blockchain 中相應方法的微調版,先前的方法將不再使用。

有了UTXO池之後,意味著我們的交易資料分開儲存到了兩個不同的資料桶中:交易資料儲存到了 block 資料桶中,而UTXO儲存到了 chainstate 資料桶中。這就需要一種同步機制來保證每當一個新的區塊產生時,UTXO池能夠及時同步最新區塊中的交易資料,畢竟我們不想頻地進行 reIndex 。因此,我們需要如下方法:

更新UTXO池:

public class UTXOSet {
 
   ... 

   /**
     * 更新UTXO池
     * <p>
     * 當一個新的區塊產生時,需要去做兩件事情:
     * 1)從UTXO池中移除花費掉了的交易輸出;
     * 2)儲存新的未花費交易輸出;
     *
     * @param tipBlock 最新的區塊
     */
    @Synchronized
    public void update(Block tipBlock) {
        if (tipBlock == null) {
            log.error("Fail to update UTXO set ! tipBlock is null !");
            throw new RuntimeException("Fail to update UTXO set ! ");
        }
        for (Transaction transaction : tipBlock.getTransactions()) {

            // 根據交易輸入排查出剩餘未被使用的交易輸出
            if (!transaction.isCoinbase()) {
                for (TXInput txInput : transaction.getInputs()) {
                    // 餘下未被使用的交易輸出
                    TXOutput[] remainderUTXOs = {};
                    String txId = Hex.encodeHexString(txInput.getTxId());
                    TXOutput[] txOutputs = RocksDBUtils.getInstance().getUTXOs(txId);

                    if (txOutputs == null) {
                        continue;
                    }

                    for (int outIndex = 0; outIndex < txOutputs.length; outIndex++) {
                        if (outIndex != txInput.getTxOutputIndex()) {
                            remainderUTXOs = ArrayUtils.add(remainderUTXOs, txOutputs[outIndex]);
                        }
                    }

                    // 沒有剩餘則刪除,否則更新
                    if (remainderUTXOs.length == 0) {
                        RocksDBUtils.getInstance().deleteUTXOs(txId);
                    } else {
                        RocksDBUtils.getInstance().putUTXOs(txId, remainderUTXOs);
                    }
                }
            }

            // 新的交易輸出儲存到DB中
            TXOutput[] txOutputs = transaction.getOutputs();
            String txId = Hex.encodeHexString(transaction.getTxId());
            RocksDBUtils.getInstance().putUTXOs(txId, txOutputs);
        }

    }
    
    ...
    
}    
複製程式碼

讓我們將 UTXOSet 用到它們所需之處去:

public class CLI {

   ...

   /**
     * 建立區塊鏈
     *
     * @param address
     */
    private void createBlockchain(String address) {
        Blockchain blockchain = Blockchain.createBlockchain(address);
        UTXOSet utxoSet = new UTXOSet(blockchain);
        utxoSet.reIndex();
        log.info("Done ! ");
    }
    
    ...
    
}    
複製程式碼

當建立一個新的區塊鏈是,我們需要重建 UTXO 池索引。截止目前,這是唯一一處用到 reIndex 的地方,儘管看起有些多餘,因為在區塊鏈建立之初僅僅只有一個區塊和一筆交易。

修改 CLI.send 介面:

public class CLI {
	
	...

   /**
     * 轉賬
     *
     * @param from
     * @param to
     * @param amount
     */
    private void send(String from, String to, int amount) throws Exception {
        
        ...
        
        Blockchain blockchain = Blockchain.createBlockchain(from);
        Transaction transaction = Transaction.newUTXOTransaction(from, to, amount, blockchain);
        Block newBlock = blockchain.mineBlock(new Transaction[]{transaction});
        new UTXOSet(blockchain).update(newBlock);
		
        ...
    }
    
    ...
    
}    
複製程式碼

當一個新的區塊產生後,需要去更新 UTXO 池資料。

讓我們來檢查一下它們的執行情況:

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

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

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

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

Elapsed Time: 164.961 seconds 
correct hash Hex: 00225493862611bc517cb6b3610e99d26d98a6b52484c9fa745df6ceff93f445 

Done ! 

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

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

$ java -jar blockchain-java-jar-with-dependencies.jar  send -from 1JgppX2xMshr35wHzvNWQBejUAZ3Te5Mdf -to 1HX7bWwCjvxkjq65GUgAVRFfTZy6yKWkoG -amount 2
Elapsed Time: 54.92 seconds 
correct hash Hex: 0001ab21f71ff2d6d532bf3b3388db790c2b03e28d7bd27bd669c5f6380a4e5b 

Success!

$ java -jar blockchain-java-jar-with-dependencies.jar  send -from 1JgppX2xMshr35wHzvNWQBejUAZ3Te5Mdf -to 1L1RoFgyjCrNPCPHmSEBtNiV3h2wiF9mZV -amount 2
Elapsed Time: 54.92 seconds 
correct hash Hex: 0009b925cc94e3db8bab2958b1fc2d1764aa15531e20756d92c3a93065c920f0 

Success!

$ java -jar blockchain-java-jar-with-dependencies.jar  getbalance -address 1JgppX2xMshr35wHzvNWQBejUAZ3Te5Mdf
Balance of '1JgppX2xMshr35wHzvNWQBejUAZ3Te5Mdf': 6

$ java -jar blockchain-java-jar-with-dependencies.jar  getbalance -address 1HX7bWwCjvxkjq65GUgAVRFfTZy6yKWkoG
Balance of '1HX7bWwCjvxkjq65GUgAVRFfTZy6yKWkoG': 2

$ java -jar blockchain-java-jar-with-dependencies.jar  getbalance -address 1L1RoFgyjCrNPCPHmSEBtNiV3h2wiF9mZV
Balance of '1L1RoFgyjCrNPCPHmSEBtNiV3h2wiF9mZV': 2
複製程式碼

獎勵機制

前面的章節中我們省略了礦工挖礦的獎勵機制。時機已經成熟,該實現它了。

礦工獎勵其實是一個 coinbase 交易(創幣交易)。當一個礦工節點開始去生產一個新的區塊時,他會從佇列中取出一些交易資料,並且為它們預製一個 coinbase 交易。這筆 coinbase 交易中僅有的交易輸出包含了礦工的公鑰hash。

只需要更新 send 命令介面,我們就可以輕鬆實現礦工的獎勵機制:

public class CLI {
	
	...

   /**
     * 轉賬
     *
     * @param from
     * @param to
     * @param amount
     */
    private void send(String from, String to, int amount) throws Exception {
        
        ...
        
        Blockchain blockchain = Blockchain.createBlockchain(from);
        // 新交易
        Transaction transaction = Transaction.newUTXOTransaction(from, to, amount, blockchain);
        // 獎勵
        Transaction rewardTx = Transaction.newCoinbaseTX(from, "");
        Block newBlock = blockchain.mineBlock(new Transaction[]{transaction, rewardTx});
        new UTXOSet(blockchain).update(newBlock);
		
        ...
    }
    
    ...
 	   
} 
複製程式碼

還需要修改交易驗證方法,coinbase 交易直接驗證通過:

public class Blockchain {
	
  /**
     * 交易簽名驗證
     *
     * @param tx
     */
    private boolean verifyTransactions(Transaction tx) {
        if (tx.isCoinbase()) {
            return true;
        }
    
        ...
    }
    
    ...
    
}    
複製程式碼

在我們的實現邏輯中,代幣的傳送也是區塊的生產者,因此,獎勵也歸他所有。

讓我們來驗證一下獎勵機制:

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

$ java -jar blockchain-java-jar-with-dependencies.jar  createwallet 
wallet address : 17crpQoWy7TEkY9UPjZ3Qt9Fc2rWPUt8KX

$ java -jar blockchain-java-jar-with-dependencies.jar  createwallet 
wallet address : 12L868QZW1ySYzf2oT5ha9py9M5JrSRhvT

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

Elapsed Time: 17.973 seconds
correct hash Hex: 0000defe83a851a5db3803d5013bbc20c6234f176b2c52ae36fdb53d28b33d93 

Done ! 

$ java -jar blockchain-java-jar-with-dependencies.jar  send -from 1MpdtjTEsDvrkrLWmMswq4K3VPtevXXnUD -to 17crpQoWy7TEkY9UPjZ3Qt9Fc2rWPUt8KX -amount 6
Elapsed Time: 30.887 seconds
correct hash Hex: 00005fd36a2609b43fd940577f93b8622e88e854f5ccfd70e113f763b6df69f7 

Success!


$ java -jar blockchain-java-jar-with-dependencies.jar  send -from 1MpdtjTEsDvrkrLWmMswq4K3VPtevXXnUD -to 12L868QZW1ySYzf2oT5ha9py9M5JrSRhvT -amount 3
Elapsed Time: 45.267 seconds
correct hash Hex: 00009fd7c59b830b60ec21ade7672921d2fb0962a1b06a42c245450e47582a13 

Success!

$ java -jar blockchain-java-jar-with-dependencies.jar  getbalance -address 1MpdtjTEsDvrkrLWmMswq4K3VPtevXXnUD
Balance of '1MpdtjTEsDvrkrLWmMswq4K3VPtevXXnUD': 21

$ java -jar blockchain-java-jar-with-dependencies.jar  getbalance -address 17crpQoWy7TEkY9UPjZ3Qt9Fc2rWPUt8KX
Balance of '17crpQoWy7TEkY9UPjZ3Qt9Fc2rWPUt8KX': 6

$ java -jar blockchain-java-jar-with-dependencies.jar  getbalance -address 12L868QZW1ySYzf2oT5ha9py9M5JrSRhvT
Balance of '12L868QZW1ySYzf2oT5ha9py9M5JrSRhvT': 3
複製程式碼

1MpdtjTEsDvrkrLWmMswq4K3VPtevXXnUD 這個地址一共收到了三份獎勵:

  • 第一次是開採創世區塊;

  • 第二次是開採區塊:00005fd36a2609b43fd940577f93b8622e88e854f5ccfd70e113f763b6df69f7

  • 第三次是開採區塊:00009fd7c59b830b60ec21ade7672921d2fb0962a1b06a42c245450e47582a13

Merkle Tree

Merkle Tree(默克爾樹) 是這篇文章中我們需要重點討論的一個機制。

正如我前面提到的那樣,整個比特幣的資料庫佔到了大約140G的磁碟空間。由於比特幣的分散式特性,網路中的每一個節點必須是獨立且自給自足的。每個比特幣節點都是路由、區塊鏈資料庫、挖礦、錢包服務的功能集合。每個節點都參與全網路的路由功能,同時也可能包含其他功能。每個節點都參與驗證並傳播交易及區塊資訊,發現並維持與對等節點的連線。一個全節點(full node)包括以下四個功能:

full node

隨著越來越多的人開始使用比特幣,這條規則開始變得越來越難以遵循:讓每一個人都去執行一個完整的節點不太現實。在中本聰釋出的 比特幣白皮書 中,針對這個問題提出了一個解決方案:Simplified Payment Verification (SPV)(簡易支付驗證)。SPV是比特幣的輕量級節點,它不需要下載所有的區塊鏈資料,也不需要驗證區塊和交易資料。相反,當SPV想要驗證一筆交易的有效性時,它會從它所連線的全節點上檢索所需要的一些資料。這種機制保證了在只有一個全節點的情況,可以執行多個SPV輕錢包節點。

更多有關SPV的介紹,請檢視:《精通比特幣(第二版)》第八章

為了使SPV成為可能,就需要有一種方法在沒有全量下載區塊資料的情況下,來檢查一個區塊是否包含了某筆交易。這就是 Merkle Tree 發揮作用的地方了。

比特幣中所使用的Merkle Tree是為了獲得交易的Hash值,隨後這個已經被Pow(工作量證明)系統認可了的Hash值會被儲存到區塊頭中。到目前為止,我們只是簡單地計算了一個區塊中每筆交易的Hash值,然後在準備Pow資料時,再對這些交易進行 SHA-256 計算。雖然這是一個用於獲取區塊交易唯一表示的一個不錯的途徑,但是它不具有到 Merkle Tree的優點。

來看一下Merkle Tree的結構:

基於Java語言構建區塊鏈(六)—— 交易(Merkle Tree)

每一個區塊都會構建一個Merkle Tree,它從最底部的葉子節點開始往上構建,每一個交易的Hash就是一個葉子節點(比特幣中用的雙SHA256演算法)。葉子節點的數量必須是偶數個,但是並不是每一個區塊都能包含偶數筆交易資料。如果存在奇數筆交易資料,那麼最後一筆交易資料將會被複制一份(這僅僅發生在Merkle Tree中,而不是區塊中)。

從下往上移動,葉子節點成對分組,它們的Hash值被連線到一起,並且在此基礎上再次計算出新的Hash值。新的Hash 形成新的樹節點。這個過程不斷地被重複,直到最後僅剩一個被稱為根節點的樹節點。這個根節點的Hash就是區塊中交易資料們的唯一代表,它會被儲存到區塊頭中,並被用於參與POW系統的計算。

Merkle樹的好處是節點可以在不下載整個塊的情況下驗證某筆交易的合法性。 為此,只需要交易Hash,Merkle樹根Hash和Merkle路徑。

Merkle Tree程式碼實現如下:

package one.wangwei.blockchain.transaction;

import com.google.common.collect.Lists;
import lombok.Data;
import one.wangwei.blockchain.util.ByteUtils;
import org.apache.commons.codec.digest.DigestUtils;

import java.util.List;

/**
 * 默克爾樹
 *
 * @author wangwei
 * @date 2018/04/15
 */
@Data
public class MerkleTree {

    /**
     * 根節點
     */
    private Node root;
    /**
     * 葉子節點Hash
     */
    private byte[][] leafHashes;

    public MerkleTree(byte[][] leafHashes) {
        constructTree(leafHashes);
    }

    /**
     * 從底部葉子節點開始往上構建整個Merkle Tree
     *
     * @param leafHashes
     */
    private void constructTree(byte[][] leafHashes) {
        if (leafHashes == null || leafHashes.length < 1) {
            throw new RuntimeException("ERROR:Fail to construct merkle tree ! leafHashes data invalid ! ");
        }
        this.leafHashes = leafHashes;
        List<Node> parents = bottomLevel(leafHashes);
        while (parents.size() > 1) {
            parents = internalLevel(parents);
        }
        root = parents.get(0);
    }

    /**
     * 構建一個層級節點
     *
     * @param children
     * @return
     */
    private List<Node> internalLevel(List<Node> children) {
        List<Node> parents = Lists.newArrayListWithCapacity(children.size() / 2);
        for (int i = 0; i < children.size() - 1; i += 2) {
            Node child1 = children.get(i);
            Node child2 = children.get(i + 1);

            Node parent = constructInternalNode(child1, child2);
            parents.add(parent);
        }

        // 內部節點奇數個,只對left節點進行計算
        if (children.size() % 2 != 0) {
            Node child = children.get(children.size() - 1);
            Node parent = constructInternalNode(child, null);
            parents.add(parent);
        }

        return parents;
    }

    /**
     * 底部節點構建
     *
     * @param hashes
     * @return
     */
    private List<Node> bottomLevel(byte[][] hashes) {
        List<Node> parents = Lists.newArrayListWithCapacity(hashes.length / 2);

        for (int i = 0; i < hashes.length - 1; i += 2) {
            Node leaf1 = constructLeafNode(hashes[i]);
            Node leaf2 = constructLeafNode(hashes[i + 1]);

            Node parent = constructInternalNode(leaf1, leaf2);
            parents.add(parent);
        }

        if (hashes.length % 2 != 0) {
            Node leaf = constructLeafNode(hashes[hashes.length - 1]);
            // 奇數個節點的情況,複製最後一個節點
            Node parent = constructInternalNode(leaf, leaf);
            parents.add(parent);
        }

        return parents;
    }

    /**
     * 構建葉子節點
     *
     * @param hash
     * @return
     */
    private static Node constructLeafNode(byte[] hash) {
        Node leaf = new Node();
        leaf.hash = hash;
        return leaf;
    }

    /**
     * 構建內部節點
     *
     * @param leftChild
     * @param rightChild
     * @return
     */
    private Node constructInternalNode(Node leftChild, Node rightChild) {
        Node parent = new Node();
        if (rightChild == null) {
            parent.hash = leftChild.hash;
        } else {
            parent.hash = internalHash(leftChild.hash, rightChild.hash);
        }
        parent.left = leftChild;
        parent.right = rightChild;
        return parent;
    }

    /**
     * 計算內部節點Hash
     *
     * @param leftChildHash
     * @param rightChildHash
     * @return
     */
    private byte[] internalHash(byte[] leftChildHash, byte[] rightChildHash) {
        byte[] mergedBytes = ByteUtils.merge(leftChildHash, rightChildHash);
        return DigestUtils.sha256(mergedBytes);
    }

    /**
     * Merkle Tree節點
     */
    @Data
    public static class Node {
        private byte[] hash;
        private Node left;
        private Node right;
    }

}

複製程式碼

然後修改 Block.hashTransaction 介面:

public class Block {
    
   ... 

   /**
     * 對區塊中的交易資訊進行Hash計算
     *
     * @return
     */
    public byte[] hashTransaction() {
        byte[][] txIdArrays = new byte[this.getTransactions().length][];
        for (int i = 0; i < this.getTransactions().length; i++) {
            txIdArrays[i] = this.getTransactions()[i].hash();
        }
        return new MerkleTree(txIdArrays).getRoot().getHash();
    }
    
    ...
	
}
複製程式碼

MerkleTree的根節點的Hash值,就是區塊中交易資訊的唯一代表。

小結

這一節我們主要是對前面的交易機制做了進一步的優化,加入UTXO池和Merkle Tree機制。

資料

  1. 原始碼
  2. The UTXO Set
  3. UTXO set statistics
  4. Merkle Tree
  5. Why every Bitcoin user should understand “SPV security”
  6. Script
  7. “Ultraprune” Bitcoin Core commit
  8. Smart contracts and Bitcoin

基於Java語言構建區塊鏈(六)—— 交易(Merkle Tree)

相關文章